From 3a4992633ee62d5edfbb484d9c6bcb3cf158489d Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 31 Jul 2023 14:34:36 +0200 Subject: Migrate server to ESM Sorry for the very big commit that may lead to git log issues and merge conflicts, but it's a major step forward: * Server can be faster at startup because imports() are async and we can easily lazy import big modules * Angular doesn't seem to support ES import (with .js extension), so we had to correctly organize peertube into a monorepo: * Use yarn workspace feature * Use typescript reference projects for dependencies * Shared projects have been moved into "packages", each one is now a node module (with a dedicated package.json/tsconfig.json) * server/tools have been moved into apps/ and is now a dedicated app bundled and published on NPM so users don't have to build peertube cli tools manually * server/tests have been moved into packages/ so we don't compile them every time we want to run the server * Use isolatedModule option: * Had to move from const enum to const (https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums) * Had to explictely specify "type" imports when used in decorators * Prefer tsx (that uses esbuild under the hood) instead of ts-node to load typescript files (tests with mocha or scripts): * To reduce test complexity as esbuild doesn't support decorator metadata, we only test server files that do not import server models * We still build tests files into js files for a faster CI * Remove unmaintained peertube CLI import script * Removed some barrels to speed up execution (less imports) --- .../tests/src/api/videos/channel-import-videos.ts | 161 +++ packages/tests/src/api/videos/index.ts | 23 + packages/tests/src/api/videos/multiple-servers.ts | 1095 ++++++++++++++++++ packages/tests/src/api/videos/resumable-upload.ts | 316 +++++ packages/tests/src/api/videos/single-server.ts | 461 ++++++++ packages/tests/src/api/videos/video-captions.ts | 189 +++ .../tests/src/api/videos/video-change-ownership.ts | 314 +++++ .../tests/src/api/videos/video-channel-syncs.ts | 321 ++++++ packages/tests/src/api/videos/video-channels.ts | 556 +++++++++ packages/tests/src/api/videos/video-comments.ts | 335 ++++++ packages/tests/src/api/videos/video-description.ts | 103 ++ packages/tests/src/api/videos/video-files.ts | 202 ++++ packages/tests/src/api/videos/video-imports.ts | 634 ++++++++++ packages/tests/src/api/videos/video-nsfw.ts | 227 ++++ packages/tests/src/api/videos/video-passwords.ts | 97 ++ .../src/api/videos/video-playlist-thumbnails.ts | 234 ++++ packages/tests/src/api/videos/video-playlists.ts | 1210 ++++++++++++++++++++ packages/tests/src/api/videos/video-privacy.ts | 294 +++++ .../tests/src/api/videos/video-schedule-update.ts | 155 +++ packages/tests/src/api/videos/video-source.ts | 448 ++++++++ .../src/api/videos/video-static-file-privacy.ts | 602 ++++++++++ packages/tests/src/api/videos/video-storyboard.ts | 213 ++++ .../tests/src/api/videos/videos-common-filters.ts | 499 ++++++++ packages/tests/src/api/videos/videos-history.ts | 230 ++++ packages/tests/src/api/videos/videos-overview.ts | 129 +++ 25 files changed, 9048 insertions(+) create mode 100644 packages/tests/src/api/videos/channel-import-videos.ts create mode 100644 packages/tests/src/api/videos/index.ts create mode 100644 packages/tests/src/api/videos/multiple-servers.ts create mode 100644 packages/tests/src/api/videos/resumable-upload.ts create mode 100644 packages/tests/src/api/videos/single-server.ts create mode 100644 packages/tests/src/api/videos/video-captions.ts create mode 100644 packages/tests/src/api/videos/video-change-ownership.ts create mode 100644 packages/tests/src/api/videos/video-channel-syncs.ts create mode 100644 packages/tests/src/api/videos/video-channels.ts create mode 100644 packages/tests/src/api/videos/video-comments.ts create mode 100644 packages/tests/src/api/videos/video-description.ts create mode 100644 packages/tests/src/api/videos/video-files.ts create mode 100644 packages/tests/src/api/videos/video-imports.ts create mode 100644 packages/tests/src/api/videos/video-nsfw.ts create mode 100644 packages/tests/src/api/videos/video-passwords.ts create mode 100644 packages/tests/src/api/videos/video-playlist-thumbnails.ts create mode 100644 packages/tests/src/api/videos/video-playlists.ts create mode 100644 packages/tests/src/api/videos/video-privacy.ts create mode 100644 packages/tests/src/api/videos/video-schedule-update.ts create mode 100644 packages/tests/src/api/videos/video-source.ts create mode 100644 packages/tests/src/api/videos/video-static-file-privacy.ts create mode 100644 packages/tests/src/api/videos/video-storyboard.ts create mode 100644 packages/tests/src/api/videos/videos-common-filters.ts create mode 100644 packages/tests/src/api/videos/videos-history.ts create mode 100644 packages/tests/src/api/videos/videos-overview.ts (limited to 'packages/tests/src/api/videos') diff --git a/packages/tests/src/api/videos/channel-import-videos.ts b/packages/tests/src/api/videos/channel-import-videos.ts new file mode 100644 index 000000000..d0e47fe95 --- /dev/null +++ b/packages/tests/src/api/videos/channel-import-videos.ts @@ -0,0 +1,161 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils' +import { + createSingleServer, + getServerImportConfig, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test videos import in a channel', function () { + if (areHttpImportTestsDisabled()) return + + function runSuite (mode: 'youtube-dl' | 'yt-dlp') { + + describe('Import using ' + mode, function () { + let server: PeerTubeServer + + before(async function () { + this.timeout(120_000) + + server = await createSingleServer(1, getServerImportConfig(mode)) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + await server.config.enableChannelSync() + }) + + it('Should import a whole channel without specifying the sync id', async function () { + this.timeout(240_000) + + await server.channels.importVideos({ channelName: server.store.channel.name, externalChannelUrl: FIXTURE_URLS.youtubeChannel }) + await waitJobs(server) + + const videos = await server.videos.listByChannel({ handle: server.store.channel.name }) + expect(videos.total).to.equal(2) + }) + + it('These imports should not have a sync id', async function () { + const { total, data } = await server.imports.getMyVideoImports() + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + for (const videoImport of data) { + expect(videoImport.videoChannelSync).to.not.exist + } + }) + + it('Should import a whole channel and specifying the sync id', async function () { + this.timeout(240_000) + + { + server.store.channel.name = 'channel2' + const { id } = await server.channels.create({ attributes: { name: server.store.channel.name } }) + server.store.channel.id = id + } + + { + const attributes = { + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelId: server.store.channel.id + } + + const { videoChannelSync } = await server.channelSyncs.create({ attributes }) + server.store.videoChannelSync = videoChannelSync + + await waitJobs(server) + } + + await server.channels.importVideos({ + channelName: server.store.channel.name, + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelSyncId: server.store.videoChannelSync.id + }) + + await waitJobs(server) + }) + + it('These imports should have a sync id', async function () { + const { total, data } = await server.imports.getMyVideoImports() + + expect(total).to.equal(4) + expect(data).to.have.lengthOf(4) + + const importsWithSyncId = data.filter(i => !!i.videoChannelSync) + expect(importsWithSyncId).to.have.lengthOf(2) + + for (const videoImport of importsWithSyncId) { + expect(videoImport.videoChannelSync).to.exist + expect(videoImport.videoChannelSync.id).to.equal(server.store.videoChannelSync.id) + } + }) + + it('Should be able to filter imports by this sync id', async function () { + const { total, data } = await server.imports.getMyVideoImports({ videoChannelSyncId: server.store.videoChannelSync.id }) + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + for (const videoImport of data) { + expect(videoImport.videoChannelSync).to.exist + expect(videoImport.videoChannelSync.id).to.equal(server.store.videoChannelSync.id) + } + }) + + it('Should limit max amount of videos synced on full sync', async function () { + this.timeout(240_000) + + await server.kill() + await server.run({ + import: { + video_channel_synchronization: { + full_sync_videos_limit: 1 + } + } + }) + + const { id } = await server.channels.create({ attributes: { name: 'channel3' } }) + const channel3Id = id + + const { videoChannelSync } = await server.channelSyncs.create({ + attributes: { + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelId: channel3Id + } + }) + const syncId = videoChannelSync.id + + await waitJobs(server) + + await server.channels.importVideos({ + channelName: 'channel3', + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelSyncId: syncId + }) + + await waitJobs(server) + + const { total, data } = await server.videos.listByChannel({ handle: 'channel3' }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + }) + + after(async function () { + await server?.kill() + }) + }) + } + + runSuite('yt-dlp') + + // FIXME: With recent changes on youtube, youtube-dl doesn't fetch live replays which means the test suite fails + // runSuite('youtube-dl') +}) diff --git a/packages/tests/src/api/videos/index.ts b/packages/tests/src/api/videos/index.ts new file mode 100644 index 000000000..fcb1d5a81 --- /dev/null +++ b/packages/tests/src/api/videos/index.ts @@ -0,0 +1,23 @@ +import './multiple-servers.js' +import './resumable-upload.js' +import './single-server.js' +import './video-captions.js' +import './video-change-ownership.js' +import './video-channels.js' +import './channel-import-videos.js' +import './video-channel-syncs.js' +import './video-comments.js' +import './video-description.js' +import './video-files.js' +import './video-imports.js' +import './video-nsfw.js' +import './video-playlists.js' +import './video-playlist-thumbnails.js' +import './video-source.js' +import './video-privacy.js' +import './video-schedule-update.js' +import './videos-common-filters.js' +import './videos-history.js' +import './videos-overview.js' +import './video-static-file-privacy.js' +import './video-storyboard.js' diff --git a/packages/tests/src/api/videos/multiple-servers.ts b/packages/tests/src/api/videos/multiple-servers.ts new file mode 100644 index 000000000..03afd7cbb --- /dev/null +++ b/packages/tests/src/api/videos/multiple-servers.ts @@ -0,0 +1,1095 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import request from 'supertest' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, VideoCommentThreadTree, VideoPrivacy } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + waitJobs +} from '@peertube/peertube-server-commands' +import { testImageGeneratedByFFmpeg, dateIsValid } from '@tests/shared/checks.js' +import { checkTmpIsEmpty } from '@tests/shared/directories.js' +import { completeVideoCheck, saveVideoInServers, checkVideoFilesWereRemoved } from '@tests/shared/videos.js' +import { checkWebTorrentWorks } from '@tests/shared/webtorrent.js' + +describe('Test multiple servers', function () { + let servers: PeerTubeServer[] = [] + const toRemove = [] + let videoUUID = '' + let videoChannelId: number + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(3) + + // Get the access tokens + await setAccessTokensToServers(servers) + + { + const videoChannel = { + name: 'super_channel_name', + displayName: 'my channel', + description: 'super channel' + } + await servers[0].channels.create({ attributes: videoChannel }) + await setDefaultChannelAvatar(servers[0], videoChannel.name) + await setDefaultAccountAvatar(servers) + + const { data } = await servers[0].channels.list({ start: 0, count: 1 }) + videoChannelId = data[0].id + } + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + // Server 1 and server 3 follow each other + await doubleFollow(servers[0], servers[2]) + // Server 2 and server 3 follow each other + await doubleFollow(servers[1], servers[2]) + }) + + it('Should not have videos for all servers', async function () { + for (const server of servers) { + const { data } = await server.videos.list() + expect(data).to.be.an('array') + expect(data.length).to.equal(0) + } + }) + + describe('Should upload the video and propagate on each server', function () { + + it('Should upload the video on server 1 and propagate on each server', async function () { + this.timeout(60000) + + const attributes = { + name: 'my super name for server 1', + category: 5, + licence: 4, + language: 'ja', + nsfw: true, + description: 'my super description for server 1', + support: 'my super support text for server 1', + originallyPublishedAt: '2019-02-10T13:38:14.449Z', + tags: [ 'tag1p1', 'tag2p1' ], + channelId: videoChannelId, + fixture: 'video_short1.webm' + } + await servers[0].videos.upload({ attributes }) + + await waitJobs(servers) + + // All servers should have this video + let publishedAt: string = null + for (const server of servers) { + const isLocal = server.port === servers[0].port + const checkAttributes = { + name: 'my super name for server 1', + category: 5, + licence: 4, + language: 'ja', + nsfw: true, + description: 'my super description for server 1', + support: 'my super support text for server 1', + originallyPublishedAt: '2019-02-10T13:38:14.449Z', + account: { + name: 'root', + host: servers[0].host + }, + isLocal, + publishedAt, + duration: 10, + tags: [ 'tag1p1', 'tag2p1' ], + privacy: VideoPrivacy.PUBLIC, + commentsEnabled: true, + downloadEnabled: true, + channel: { + displayName: 'my channel', + name: 'super_channel_name', + description: 'super channel', + isLocal + }, + fixture: 'video_short1.webm', + files: [ + { + resolution: 720, + size: 572456 + } + ] + } + + const { data } = await server.videos.list() + expect(data).to.be.an('array') + expect(data.length).to.equal(1) + const video = data[0] + + await completeVideoCheck({ server, originServer: servers[0], videoUUID: video.uuid, attributes: checkAttributes }) + publishedAt = video.publishedAt as string + + expect(video.channel.avatars).to.have.lengthOf(2) + expect(video.account.avatars).to.have.lengthOf(2) + + for (const image of [ ...video.channel.avatars, ...video.account.avatars ]) { + expect(image.createdAt).to.exist + expect(image.updatedAt).to.exist + expect(image.width).to.be.above(20).and.below(1000) + expect(image.path).to.exist + + await makeGetRequest({ + url: server.url, + path: image.path, + expectedStatus: HttpStatusCode.OK_200 + }) + } + } + }) + + it('Should upload the video on server 2 and propagate on each server', async function () { + this.timeout(240000) + + const user = { + username: 'user1', + password: 'super_password' + } + await servers[1].users.create({ username: user.username, password: user.password }) + const userAccessToken = await servers[1].login.getAccessToken(user) + + const attributes = { + name: 'my super name for server 2', + category: 4, + licence: 3, + language: 'de', + nsfw: true, + description: 'my super description for server 2', + support: 'my super support text for server 2', + tags: [ 'tag1p2', 'tag2p2', 'tag3p2' ], + fixture: 'video_short2.webm', + thumbnailfile: 'custom-thumbnail.jpg', + previewfile: 'custom-preview.jpg' + } + await servers[1].videos.upload({ token: userAccessToken, attributes, mode: 'resumable' }) + + // Transcoding + await waitJobs(servers) + + // All servers should have this video + for (const server of servers) { + const isLocal = server.url === servers[1].url + const checkAttributes = { + name: 'my super name for server 2', + category: 4, + licence: 3, + language: 'de', + nsfw: true, + description: 'my super description for server 2', + support: 'my super support text for server 2', + account: { + name: 'user1', + host: servers[1].host + }, + isLocal, + commentsEnabled: true, + downloadEnabled: true, + duration: 5, + tags: [ 'tag1p2', 'tag2p2', 'tag3p2' ], + privacy: VideoPrivacy.PUBLIC, + channel: { + displayName: 'Main user1 channel', + name: 'user1_channel', + description: 'super channel', + isLocal + }, + fixture: 'video_short2.webm', + files: [ + { + resolution: 240, + size: 270000 + }, + { + resolution: 360, + size: 359000 + }, + { + resolution: 480, + size: 465000 + }, + { + resolution: 720, + size: 750000 + } + ], + thumbnailfile: 'custom-thumbnail', + previewfile: 'custom-preview' + } + + const { data } = await server.videos.list() + expect(data).to.be.an('array') + expect(data.length).to.equal(2) + const video = data[1] + + await completeVideoCheck({ server, originServer: servers[1], videoUUID: video.uuid, attributes: checkAttributes }) + } + }) + + it('Should upload two videos on server 3 and propagate on each server', async function () { + this.timeout(45000) + + { + const attributes = { + name: 'my super name for server 3', + category: 6, + licence: 5, + language: 'de', + nsfw: true, + description: 'my super description for server 3', + support: 'my super support text for server 3', + tags: [ 'tag1p3' ], + fixture: 'video_short3.webm' + } + await servers[2].videos.upload({ attributes }) + } + + { + const attributes = { + name: 'my super name for server 3-2', + category: 7, + licence: 6, + language: 'ko', + nsfw: false, + description: 'my super description for server 3-2', + support: 'my super support text for server 3-2', + tags: [ 'tag2p3', 'tag3p3', 'tag4p3' ], + fixture: 'video_short.webm' + } + await servers[2].videos.upload({ attributes }) + } + + await waitJobs(servers) + + // All servers should have this video + for (const server of servers) { + const isLocal = server.url === servers[2].url + const { data } = await server.videos.list() + + expect(data).to.be.an('array') + expect(data.length).to.equal(4) + + // We not sure about the order of the two last uploads + let video1 = null + let video2 = null + if (data[2].name === 'my super name for server 3') { + video1 = data[2] + video2 = data[3] + } else { + video1 = data[3] + video2 = data[2] + } + + const checkAttributesVideo1 = { + name: 'my super name for server 3', + category: 6, + licence: 5, + language: 'de', + nsfw: true, + description: 'my super description for server 3', + support: 'my super support text for server 3', + account: { + name: 'root', + host: servers[2].host + }, + isLocal, + duration: 5, + commentsEnabled: true, + downloadEnabled: true, + tags: [ 'tag1p3' ], + privacy: VideoPrivacy.PUBLIC, + channel: { + displayName: 'Main root channel', + name: 'root_channel', + description: '', + isLocal + }, + fixture: 'video_short3.webm', + files: [ + { + resolution: 720, + size: 292677 + } + ] + } + await completeVideoCheck({ server, originServer: servers[2], videoUUID: video1.uuid, attributes: checkAttributesVideo1 }) + + const checkAttributesVideo2 = { + name: 'my super name for server 3-2', + category: 7, + licence: 6, + language: 'ko', + nsfw: false, + description: 'my super description for server 3-2', + support: 'my super support text for server 3-2', + account: { + name: 'root', + host: servers[2].host + }, + commentsEnabled: true, + downloadEnabled: true, + isLocal, + duration: 5, + tags: [ 'tag2p3', 'tag3p3', 'tag4p3' ], + privacy: VideoPrivacy.PUBLIC, + channel: { + displayName: 'Main root channel', + name: 'root_channel', + description: '', + isLocal + }, + fixture: 'video_short.webm', + files: [ + { + resolution: 720, + size: 218910 + } + ] + } + await completeVideoCheck({ server, originServer: servers[2], videoUUID: video2.uuid, attributes: checkAttributesVideo2 }) + } + }) + }) + + describe('It should list local videos', function () { + it('Should list only local videos on server 1', async function () { + const { data, total } = await servers[0].videos.list({ isLocal: true }) + + expect(total).to.equal(1) + expect(data).to.be.an('array') + expect(data.length).to.equal(1) + expect(data[0].name).to.equal('my super name for server 1') + }) + + it('Should list only local videos on server 2', async function () { + const { data, total } = await servers[1].videos.list({ isLocal: true }) + + expect(total).to.equal(1) + expect(data).to.be.an('array') + expect(data.length).to.equal(1) + expect(data[0].name).to.equal('my super name for server 2') + }) + + it('Should list only local videos on server 3', async function () { + const { data, total } = await servers[2].videos.list({ isLocal: true }) + + expect(total).to.equal(2) + expect(data).to.be.an('array') + expect(data.length).to.equal(2) + expect(data[0].name).to.equal('my super name for server 3') + expect(data[1].name).to.equal('my super name for server 3-2') + }) + }) + + describe('Should seed the uploaded video', function () { + + it('Should add the file 1 by asking server 3', async function () { + this.retries(2) + this.timeout(30000) + + const { data } = await servers[2].videos.list() + + const video = data[0] + toRemove.push(data[2]) + toRemove.push(data[3]) + + const videoDetails = await servers[2].videos.get({ id: video.id }) + + await checkWebTorrentWorks(videoDetails.files[0].magnetUri) + }) + + it('Should add the file 2 by asking server 1', async function () { + this.retries(2) + this.timeout(30000) + + const { data } = await servers[0].videos.list() + + const video = data[1] + const videoDetails = await servers[0].videos.get({ id: video.id }) + + await checkWebTorrentWorks(videoDetails.files[0].magnetUri) + }) + + it('Should add the file 3 by asking server 2', async function () { + this.retries(2) + this.timeout(30000) + + const { data } = await servers[1].videos.list() + + const video = data[2] + const videoDetails = await servers[1].videos.get({ id: video.id }) + + await checkWebTorrentWorks(videoDetails.files[0].magnetUri) + }) + + it('Should add the file 3-2 by asking server 1', async function () { + this.retries(2) + this.timeout(30000) + + const { data } = await servers[0].videos.list() + + const video = data[3] + const videoDetails = await servers[0].videos.get({ id: video.id }) + + await checkWebTorrentWorks(videoDetails.files[0].magnetUri) + }) + + it('Should add the file 2 in 360p by asking server 1', async function () { + this.retries(2) + this.timeout(30000) + + const { data } = await servers[0].videos.list() + + const video = data.find(v => v.name === 'my super name for server 2') + const videoDetails = await servers[0].videos.get({ id: video.id }) + + const file = videoDetails.files.find(f => f.resolution.id === 360) + expect(file).not.to.be.undefined + + await checkWebTorrentWorks(file.magnetUri) + }) + }) + + describe('Should update video views, likes and dislikes', function () { + let localVideosServer3 = [] + let remoteVideosServer1 = [] + let remoteVideosServer2 = [] + let remoteVideosServer3 = [] + + before(async function () { + { + const { data } = await servers[0].videos.list() + remoteVideosServer1 = data.filter(video => video.isLocal === false).map(video => video.uuid) + } + + { + const { data } = await servers[1].videos.list() + remoteVideosServer2 = data.filter(video => video.isLocal === false).map(video => video.uuid) + } + + { + const { data } = await servers[2].videos.list() + localVideosServer3 = data.filter(video => video.isLocal === true).map(video => video.uuid) + remoteVideosServer3 = data.filter(video => video.isLocal === false).map(video => video.uuid) + } + }) + + it('Should view multiple videos on owned servers', async function () { + this.timeout(30000) + + await servers[2].views.simulateView({ id: localVideosServer3[0] }) + await wait(1000) + + await servers[2].views.simulateView({ id: localVideosServer3[0] }) + await servers[2].views.simulateView({ id: localVideosServer3[1] }) + + await wait(1000) + + await servers[2].views.simulateView({ id: localVideosServer3[0] }) + await servers[2].views.simulateView({ id: localVideosServer3[0] }) + + await waitJobs(servers) + + for (const server of servers) { + await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } }) + } + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const video0 = data.find(v => v.uuid === localVideosServer3[0]) + const video1 = data.find(v => v.uuid === localVideosServer3[1]) + + expect(video0.views).to.equal(3) + expect(video1.views).to.equal(1) + } + }) + + it('Should view multiple videos on each servers', async function () { + this.timeout(45000) + + const tasks: Promise[] = [] + tasks.push(servers[0].views.simulateView({ id: remoteVideosServer1[0] })) + tasks.push(servers[1].views.simulateView({ id: remoteVideosServer2[0] })) + tasks.push(servers[1].views.simulateView({ id: remoteVideosServer2[0] })) + tasks.push(servers[2].views.simulateView({ id: remoteVideosServer3[0] })) + tasks.push(servers[2].views.simulateView({ id: remoteVideosServer3[1] })) + tasks.push(servers[2].views.simulateView({ id: remoteVideosServer3[1] })) + tasks.push(servers[2].views.simulateView({ id: remoteVideosServer3[1] })) + tasks.push(servers[2].views.simulateView({ id: localVideosServer3[1] })) + tasks.push(servers[2].views.simulateView({ id: localVideosServer3[1] })) + tasks.push(servers[2].views.simulateView({ id: localVideosServer3[1] })) + + await Promise.all(tasks) + + await waitJobs(servers) + + for (const server of servers) { + await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } }) + } + + await waitJobs(servers) + + let baseVideos = null + + for (const server of servers) { + const { data } = await server.videos.list() + + // Initialize base videos for future comparisons + if (baseVideos === null) { + baseVideos = data + continue + } + + for (const baseVideo of baseVideos) { + const sameVideo = data.find(video => video.name === baseVideo.name) + expect(baseVideo.views).to.equal(sameVideo.views) + } + } + }) + + it('Should like and dislikes videos on different services', async function () { + this.timeout(50000) + + await servers[0].videos.rate({ id: remoteVideosServer1[0], rating: 'like' }) + await wait(500) + await servers[0].videos.rate({ id: remoteVideosServer1[0], rating: 'dislike' }) + await wait(500) + await servers[0].videos.rate({ id: remoteVideosServer1[0], rating: 'like' }) + await servers[2].videos.rate({ id: localVideosServer3[1], rating: 'like' }) + await wait(500) + await servers[2].videos.rate({ id: localVideosServer3[1], rating: 'dislike' }) + await servers[2].videos.rate({ id: remoteVideosServer3[1], rating: 'dislike' }) + await wait(500) + await servers[2].videos.rate({ id: remoteVideosServer3[0], rating: 'like' }) + + await waitJobs(servers) + await wait(5000) + await waitJobs(servers) + + let baseVideos = null + for (const server of servers) { + const { data } = await server.videos.list() + + // Initialize base videos for future comparisons + if (baseVideos === null) { + baseVideos = data + continue + } + + for (const baseVideo of baseVideos) { + const sameVideo = data.find(video => video.name === baseVideo.name) + expect(baseVideo.likes).to.equal(sameVideo.likes, `Likes of ${sameVideo.uuid} do not correspond`) + expect(baseVideo.dislikes).to.equal(sameVideo.dislikes, `Dislikes of ${sameVideo.uuid} do not correspond`) + } + } + }) + }) + + describe('Should manipulate these videos', function () { + let updatedAtMin: Date + + it('Should update video 3', async function () { + this.timeout(30000) + + const attributes = { + name: 'my super video updated', + category: 10, + licence: 7, + language: 'fr', + nsfw: true, + description: 'my super description updated', + support: 'my super support text updated', + tags: [ 'tag_up_1', 'tag_up_2' ], + thumbnailfile: 'custom-thumbnail.jpg', + originallyPublishedAt: '2019-02-11T13:38:14.449Z', + previewfile: 'custom-preview.jpg' + } + + updatedAtMin = new Date() + await servers[2].videos.update({ id: toRemove[0].id, attributes }) + + await waitJobs(servers) + }) + + it('Should have the video 3 updated on each server', async function () { + this.timeout(30000) + + for (const server of servers) { + const { data } = await server.videos.list() + + const videoUpdated = data.find(video => video.name === 'my super video updated') + expect(!!videoUpdated).to.be.true + + expect(new Date(videoUpdated.updatedAt)).to.be.greaterThan(updatedAtMin) + + const isLocal = server.url === servers[2].url + const checkAttributes = { + name: 'my super video updated', + category: 10, + licence: 7, + language: 'fr', + nsfw: true, + description: 'my super description updated', + support: 'my super support text updated', + originallyPublishedAt: '2019-02-11T13:38:14.449Z', + account: { + name: 'root', + host: servers[2].host + }, + isLocal, + duration: 5, + commentsEnabled: true, + downloadEnabled: true, + tags: [ 'tag_up_1', 'tag_up_2' ], + privacy: VideoPrivacy.PUBLIC, + channel: { + displayName: 'Main root channel', + name: 'root_channel', + description: '', + isLocal + }, + fixture: 'video_short3.webm', + files: [ + { + resolution: 720, + size: 292677 + } + ], + thumbnailfile: 'custom-thumbnail', + previewfile: 'custom-preview' + } + await completeVideoCheck({ server, originServer: servers[2], videoUUID: videoUpdated.uuid, attributes: checkAttributes }) + } + }) + + it('Should only update thumbnail and update updatedAt attribute', async function () { + this.timeout(30000) + + const attributes = { + thumbnailfile: 'custom-thumbnail.jpg' + } + + updatedAtMin = new Date() + await servers[2].videos.update({ id: toRemove[0].id, attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const videoUpdated = data.find(video => video.name === 'my super video updated') + expect(new Date(videoUpdated.updatedAt)).to.be.greaterThan(updatedAtMin) + } + }) + + it('Should remove the videos 3 and 3-2 by asking server 3 and correctly delete files', async function () { + this.timeout(30000) + + for (const id of [ toRemove[0].id, toRemove[1].id ]) { + await saveVideoInServers(servers, id) + + await servers[2].videos.remove({ id }) + + await waitJobs(servers) + + for (const server of servers) { + await checkVideoFilesWereRemoved({ server, video: server.store.videoDetails }) + } + } + }) + + it('Should have videos 1 and 3 on each server', async function () { + for (const server of servers) { + const { data } = await server.videos.list() + + expect(data).to.be.an('array') + expect(data.length).to.equal(2) + expect(data[0].name).not.to.equal(data[1].name) + expect(data[0].name).not.to.equal(toRemove[0].name) + expect(data[1].name).not.to.equal(toRemove[0].name) + expect(data[0].name).not.to.equal(toRemove[1].name) + expect(data[1].name).not.to.equal(toRemove[1].name) + + videoUUID = data.find(video => video.name === 'my super name for server 1').uuid + } + }) + + it('Should get the same video by UUID on each server', async function () { + let baseVideo = null + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + if (baseVideo === null) { + baseVideo = video + continue + } + + expect(baseVideo.name).to.equal(video.name) + expect(baseVideo.uuid).to.equal(video.uuid) + expect(baseVideo.category.id).to.equal(video.category.id) + expect(baseVideo.language.id).to.equal(video.language.id) + expect(baseVideo.licence.id).to.equal(video.licence.id) + expect(baseVideo.nsfw).to.equal(video.nsfw) + expect(baseVideo.account.name).to.equal(video.account.name) + expect(baseVideo.account.displayName).to.equal(video.account.displayName) + expect(baseVideo.account.url).to.equal(video.account.url) + expect(baseVideo.account.host).to.equal(video.account.host) + expect(baseVideo.tags).to.deep.equal(video.tags) + } + }) + + it('Should get the preview from each server', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + await testImageGeneratedByFFmpeg(server.url, 'video_short1-preview.webm', video.previewPath) + } + }) + }) + + describe('Should comment these videos', function () { + let childOfFirstChild: VideoCommentThreadTree + + it('Should add comment (threads and replies)', async function () { + this.timeout(25000) + + { + const text = 'my super first comment' + await servers[0].comments.createThread({ videoId: videoUUID, text }) + } + + { + const text = 'my super second comment' + await servers[2].comments.createThread({ videoId: videoUUID, text }) + } + + await waitJobs(servers) + + { + const threadId = await servers[1].comments.findCommentId({ videoId: videoUUID, text: 'my super first comment' }) + + const text = 'my super answer to thread 1' + await servers[1].comments.addReply({ videoId: videoUUID, toCommentId: threadId, text }) + } + + await waitJobs(servers) + + { + const threadId = await servers[2].comments.findCommentId({ videoId: videoUUID, text: 'my super first comment' }) + + const body = await servers[2].comments.getThread({ videoId: videoUUID, threadId }) + const childCommentId = body.children[0].comment.id + + const text3 = 'my second answer to thread 1' + await servers[2].comments.addReply({ videoId: videoUUID, toCommentId: threadId, text: text3 }) + + const text2 = 'my super answer to answer of thread 1' + await servers[2].comments.addReply({ videoId: videoUUID, toCommentId: childCommentId, text: text2 }) + } + + await waitJobs(servers) + }) + + it('Should have these threads', async function () { + for (const server of servers) { + const body = await server.comments.listThreads({ videoId: videoUUID }) + + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + + { + const comment = body.data.find(c => c.text === 'my super first comment') + expect(comment).to.not.be.undefined + expect(comment.inReplyToCommentId).to.be.null + expect(comment.account.name).to.equal('root') + expect(comment.account.host).to.equal(servers[0].host) + expect(comment.totalReplies).to.equal(3) + expect(dateIsValid(comment.createdAt as string)).to.be.true + expect(dateIsValid(comment.updatedAt as string)).to.be.true + } + + { + const comment = body.data.find(c => c.text === 'my super second comment') + expect(comment).to.not.be.undefined + expect(comment.inReplyToCommentId).to.be.null + expect(comment.account.name).to.equal('root') + expect(comment.account.host).to.equal(servers[2].host) + expect(comment.totalReplies).to.equal(0) + expect(dateIsValid(comment.createdAt as string)).to.be.true + expect(dateIsValid(comment.updatedAt as string)).to.be.true + } + } + }) + + it('Should have these comments', async function () { + for (const server of servers) { + const body = await server.comments.listThreads({ videoId: videoUUID }) + const threadId = body.data.find(c => c.text === 'my super first comment').id + + const tree = await server.comments.getThread({ videoId: videoUUID, threadId }) + + expect(tree.comment.text).equal('my super first comment') + expect(tree.comment.account.name).equal('root') + expect(tree.comment.account.host).equal(servers[0].host) + expect(tree.children).to.have.lengthOf(2) + + const firstChild = tree.children[0] + expect(firstChild.comment.text).to.equal('my super answer to thread 1') + expect(firstChild.comment.account.name).equal('root') + expect(firstChild.comment.account.host).equal(servers[1].host) + expect(firstChild.children).to.have.lengthOf(1) + + childOfFirstChild = firstChild.children[0] + expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1') + expect(childOfFirstChild.comment.account.name).equal('root') + expect(childOfFirstChild.comment.account.host).equal(servers[2].host) + expect(childOfFirstChild.children).to.have.lengthOf(0) + + const secondChild = tree.children[1] + expect(secondChild.comment.text).to.equal('my second answer to thread 1') + expect(secondChild.comment.account.name).equal('root') + expect(secondChild.comment.account.host).equal(servers[2].host) + expect(secondChild.children).to.have.lengthOf(0) + } + }) + + it('Should delete a reply', async function () { + this.timeout(30000) + + await servers[2].comments.delete({ videoId: videoUUID, commentId: childOfFirstChild.comment.id }) + + await waitJobs(servers) + }) + + it('Should have this comment marked as deleted', async function () { + for (const server of servers) { + const { data } = await server.comments.listThreads({ videoId: videoUUID }) + const threadId = data.find(c => c.text === 'my super first comment').id + + const tree = await server.comments.getThread({ videoId: videoUUID, threadId }) + expect(tree.comment.text).equal('my super first comment') + + const firstChild = tree.children[0] + expect(firstChild.comment.text).to.equal('my super answer to thread 1') + expect(firstChild.children).to.have.lengthOf(1) + + const deletedComment = firstChild.children[0].comment + expect(deletedComment.isDeleted).to.be.true + expect(deletedComment.deletedAt).to.not.be.null + expect(deletedComment.account).to.be.null + expect(deletedComment.text).to.equal('') + + const secondChild = tree.children[1] + expect(secondChild.comment.text).to.equal('my second answer to thread 1') + } + }) + + it('Should delete the thread comments', async function () { + this.timeout(30000) + + const { data } = await servers[0].comments.listThreads({ videoId: videoUUID }) + const commentId = data.find(c => c.text === 'my super first comment').id + await servers[0].comments.delete({ videoId: videoUUID, commentId }) + + await waitJobs(servers) + }) + + it('Should have the threads marked as deleted on other servers too', async function () { + for (const server of servers) { + const body = await server.comments.listThreads({ videoId: videoUUID }) + + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + + { + const comment = body.data[0] + expect(comment).to.not.be.undefined + expect(comment.inReplyToCommentId).to.be.null + expect(comment.account.name).to.equal('root') + expect(comment.account.host).to.equal(servers[2].host) + expect(comment.totalReplies).to.equal(0) + expect(dateIsValid(comment.createdAt as string)).to.be.true + expect(dateIsValid(comment.updatedAt as string)).to.be.true + } + + { + const deletedComment = body.data[1] + expect(deletedComment).to.not.be.undefined + expect(deletedComment.isDeleted).to.be.true + expect(deletedComment.deletedAt).to.not.be.null + expect(deletedComment.text).to.equal('') + expect(deletedComment.inReplyToCommentId).to.be.null + expect(deletedComment.account).to.be.null + expect(deletedComment.totalReplies).to.equal(2) + expect(dateIsValid(deletedComment.createdAt as string)).to.be.true + expect(dateIsValid(deletedComment.updatedAt as string)).to.be.true + expect(dateIsValid(deletedComment.deletedAt as string)).to.be.true + } + } + }) + + it('Should delete a remote thread by the origin server', async function () { + this.timeout(5000) + + const { data } = await servers[0].comments.listThreads({ videoId: videoUUID }) + const commentId = data.find(c => c.text === 'my super second comment').id + await servers[0].comments.delete({ videoId: videoUUID, commentId }) + + await waitJobs(servers) + }) + + it('Should have the threads marked as deleted on other servers too', async function () { + for (const server of servers) { + const body = await server.comments.listThreads({ videoId: videoUUID }) + + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(2) + + { + const comment = body.data[0] + expect(comment.text).to.equal('') + expect(comment.isDeleted).to.be.true + expect(comment.createdAt).to.not.be.null + expect(comment.deletedAt).to.not.be.null + expect(comment.account).to.be.null + expect(comment.totalReplies).to.equal(0) + } + + { + const comment = body.data[1] + expect(comment.text).to.equal('') + expect(comment.isDeleted).to.be.true + expect(comment.createdAt).to.not.be.null + expect(comment.deletedAt).to.not.be.null + expect(comment.account).to.be.null + expect(comment.totalReplies).to.equal(2) + } + } + }) + + it('Should disable comments and download', async function () { + this.timeout(20000) + + const attributes = { + commentsEnabled: false, + downloadEnabled: false + } + + await servers[0].videos.update({ id: videoUUID, attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(video.commentsEnabled).to.be.false + expect(video.downloadEnabled).to.be.false + + const text = 'my super forbidden comment' + await server.comments.createThread({ videoId: videoUUID, text, expectedStatus: HttpStatusCode.CONFLICT_409 }) + } + }) + }) + + describe('With minimum parameters', function () { + it('Should upload and propagate the video', async function () { + this.timeout(120000) + + const path = '/api/v1/videos/upload' + + const req = request(servers[1].url) + .post(path) + .set('Accept', 'application/json') + .set('Authorization', 'Bearer ' + servers[1].accessToken) + .field('name', 'minimum parameters') + .field('privacy', '1') + .field('channelId', '1') + + await req.attach('videofile', buildAbsoluteFixturePath('video_short.webm')) + .expect(HttpStatusCode.OK_200) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + const video = data.find(v => v.name === 'minimum parameters') + + const isLocal = server.url === servers[1].url + const checkAttributes = { + name: 'minimum parameters', + category: null, + licence: null, + language: null, + nsfw: false, + description: null, + support: null, + account: { + name: 'root', + host: servers[1].host + }, + isLocal, + duration: 5, + commentsEnabled: true, + downloadEnabled: true, + tags: [], + privacy: VideoPrivacy.PUBLIC, + channel: { + displayName: 'Main root channel', + name: 'root_channel', + description: '', + isLocal + }, + fixture: 'video_short.webm', + files: [ + { + resolution: 720, + size: 61000 + }, + { + resolution: 480, + size: 40000 + }, + { + resolution: 360, + size: 32000 + }, + { + resolution: 240, + size: 23000 + } + ] + } + await completeVideoCheck({ server, originServer: servers[1], videoUUID: video.uuid, attributes: checkAttributes }) + } + }) + }) + + describe('TMP directory', function () { + it('Should have an empty tmp directory', async function () { + for (const server of servers) { + await checkTmpIsEmpty(server) + } + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/resumable-upload.ts b/packages/tests/src/api/videos/resumable-upload.ts new file mode 100644 index 000000000..628e0298c --- /dev/null +++ b/packages/tests/src/api/videos/resumable-upload.ts @@ -0,0 +1,316 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pathExists } from 'fs-extra/esm' +import { readdir, stat } from 'fs/promises' +import { join } from 'path' +import { HttpStatusCode, HttpStatusCodeType, VideoPrivacy } from '@peertube/peertube-models' +import { buildAbsoluteFixturePath, sha1 } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +// Most classic resumable upload tests are done in other test suites + +describe('Test resumable upload', function () { + const path = '/api/v1/videos/upload-resumable' + const defaultFixture = 'video_short.mp4' + let server: PeerTubeServer + let rootId: number + let userAccessToken: string + let userChannelId: number + + async function buildSize (fixture: string, size?: number) { + if (size !== undefined) return size + + const baseFixture = buildAbsoluteFixturePath(fixture) + return (await stat(baseFixture)).size + } + + async function prepareUpload (options: { + channelId?: number + token?: string + size?: number + originalName?: string + lastModified?: number + } = {}) { + const { token, originalName, lastModified } = options + + const size = await buildSize(defaultFixture, options.size) + + const attributes = { + name: 'video', + channelId: options.channelId ?? server.store.channel.id, + privacy: VideoPrivacy.PUBLIC, + fixture: defaultFixture + } + + const mimetype = 'video/mp4' + + const res = await server.videos.prepareResumableUpload({ path, token, attributes, size, mimetype, originalName, lastModified }) + + return res.header['location'].split('?')[1] + } + + async function sendChunks (options: { + token?: string + pathUploadId: string + size?: number + expectedStatus?: HttpStatusCodeType + contentLength?: number + contentRange?: string + contentRangeBuilder?: (start: number, chunk: any) => string + digestBuilder?: (chunk: any) => string + }) { + const { token, pathUploadId, expectedStatus, contentLength, contentRangeBuilder, digestBuilder } = options + + const size = await buildSize(defaultFixture, options.size) + const absoluteFilePath = buildAbsoluteFixturePath(defaultFixture) + + return server.videos.sendResumableChunks({ + token, + path, + pathUploadId, + videoFilePath: absoluteFilePath, + size, + contentLength, + contentRangeBuilder, + digestBuilder, + expectedStatus + }) + } + + async function checkFileSize (uploadIdArg: string, expectedSize: number | null) { + const uploadId = uploadIdArg.replace(/^upload_id=/, '') + + const subPath = join('tmp', 'resumable-uploads', `${rootId}-${uploadId}.mp4`) + const filePath = server.servers.buildDirectory(subPath) + const exists = await pathExists(filePath) + + if (expectedSize === null) { + expect(exists).to.be.false + return + } + + expect(exists).to.be.true + + expect((await stat(filePath)).size).to.equal(expectedSize) + } + + async function countResumableUploads (wait?: number) { + const subPath = join('tmp', 'resumable-uploads') + const filePath = server.servers.buildDirectory(subPath) + await new Promise(resolve => setTimeout(resolve, wait)) + const files = await readdir(filePath) + return files.length + } + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + const body = await server.users.getMyInfo() + rootId = body.id + + { + userAccessToken = await server.users.generateUserAndToken('user1') + const { videoChannels } = await server.users.getMyInfo({ token: userAccessToken }) + userChannelId = videoChannels[0].id + } + + await server.users.update({ userId: rootId, videoQuota: 10_000_000 }) + }) + + describe('Directory cleaning', function () { + + it('Should correctly delete files after an upload', async function () { + const uploadId = await prepareUpload() + await sendChunks({ pathUploadId: uploadId }) + await server.videos.endResumableUpload({ path, pathUploadId: uploadId }) + + expect(await countResumableUploads()).to.equal(0) + }) + + it('Should correctly delete corrupt files', async function () { + const uploadId = await prepareUpload({ size: 8 * 1024 }) + await sendChunks({ pathUploadId: uploadId, size: 8 * 1024, expectedStatus: HttpStatusCode.UNPROCESSABLE_ENTITY_422 }) + + expect(await countResumableUploads(2000)).to.equal(0) + }) + + it('Should not delete files after an unfinished upload', async function () { + await prepareUpload() + + expect(await countResumableUploads()).to.equal(2) + }) + + it('Should not delete recent uploads', async function () { + await server.debug.sendCommand({ body: { command: 'remove-dandling-resumable-uploads' } }) + + expect(await countResumableUploads()).to.equal(2) + }) + + it('Should delete old uploads', async function () { + await server.debug.sendCommand({ body: { command: 'remove-dandling-resumable-uploads' } }) + + expect(await countResumableUploads()).to.equal(0) + }) + }) + + describe('Resumable upload and chunks', function () { + + it('Should accept the same amount of chunks', async function () { + const uploadId = await prepareUpload() + await sendChunks({ pathUploadId: uploadId }) + + await checkFileSize(uploadId, null) + }) + + it('Should not accept more chunks than expected', async function () { + const uploadId = await prepareUpload({ size: 100 }) + + await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.CONFLICT_409 }) + await checkFileSize(uploadId, 0) + }) + + it('Should not accept more chunks than expected with an invalid content length/content range', async function () { + const uploadId = await prepareUpload({ size: 1500 }) + + // Content length check can be different depending on the node version + try { + await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.CONFLICT_409, contentLength: 1000 }) + await checkFileSize(uploadId, 0) + } catch { + await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.BAD_REQUEST_400, contentLength: 1000 }) + await checkFileSize(uploadId, 0) + } + }) + + it('Should not accept more chunks than expected with an invalid content length', async function () { + const uploadId = await prepareUpload({ size: 500 }) + + const size = 1000 + + // Content length check seems to have changed in v16 + const expectedStatus = process.version.startsWith('v16') + ? HttpStatusCode.CONFLICT_409 + : HttpStatusCode.BAD_REQUEST_400 + + const contentRangeBuilder = (start: number) => `bytes ${start}-${start + size - 1}/${size}` + await sendChunks({ pathUploadId: uploadId, expectedStatus, contentRangeBuilder, contentLength: size }) + await checkFileSize(uploadId, 0) + }) + + it('Should be able to accept 2 PUT requests', async function () { + const uploadId = await prepareUpload() + + const result1 = await sendChunks({ pathUploadId: uploadId }) + const result2 = await sendChunks({ pathUploadId: uploadId }) + + expect(result1.body.video.uuid).to.exist + expect(result1.body.video.uuid).to.equal(result2.body.video.uuid) + + expect(result1.headers['x-resumable-upload-cached']).to.not.exist + expect(result2.headers['x-resumable-upload-cached']).to.equal('true') + + await checkFileSize(uploadId, null) + }) + + it('Should not have the same upload id with 2 different users', async function () { + const originalName = 'toto.mp4' + const lastModified = new Date().getTime() + + const uploadId1 = await prepareUpload({ originalName, lastModified, token: server.accessToken }) + const uploadId2 = await prepareUpload({ originalName, lastModified, channelId: userChannelId, token: userAccessToken }) + + expect(uploadId1).to.not.equal(uploadId2) + }) + + it('Should have the same upload id with the same user', async function () { + const originalName = 'toto.mp4' + const lastModified = new Date().getTime() + + const uploadId1 = await prepareUpload({ originalName, lastModified }) + const uploadId2 = await prepareUpload({ originalName, lastModified }) + + expect(uploadId1).to.equal(uploadId2) + }) + + it('Should not cache a request with 2 different users', async function () { + const originalName = 'toto.mp4' + const lastModified = new Date().getTime() + + const uploadId = await prepareUpload({ originalName, lastModified, token: server.accessToken }) + + await sendChunks({ pathUploadId: uploadId, token: server.accessToken }) + await sendChunks({ pathUploadId: uploadId, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should not cache a request after a delete', async function () { + const originalName = 'toto.mp4' + const lastModified = new Date().getTime() + const uploadId1 = await prepareUpload({ originalName, lastModified, token: server.accessToken }) + + await sendChunks({ pathUploadId: uploadId1 }) + await server.videos.endResumableUpload({ path, pathUploadId: uploadId1 }) + + const uploadId2 = await prepareUpload({ originalName, lastModified, token: server.accessToken }) + expect(uploadId1).to.equal(uploadId2) + + const result2 = await sendChunks({ pathUploadId: uploadId1 }) + expect(result2.headers['x-resumable-upload-cached']).to.not.exist + }) + + it('Should not cache after video deletion', async function () { + const originalName = 'toto.mp4' + const lastModified = new Date().getTime() + + const uploadId1 = await prepareUpload({ originalName, lastModified }) + const result1 = await sendChunks({ pathUploadId: uploadId1 }) + await server.videos.remove({ id: result1.body.video.uuid }) + + const uploadId2 = await prepareUpload({ originalName, lastModified }) + const result2 = await sendChunks({ pathUploadId: uploadId2 }) + expect(result1.body.video.uuid).to.not.equal(result2.body.video.uuid) + + expect(result2.headers['x-resumable-upload-cached']).to.not.exist + + await checkFileSize(uploadId1, null) + await checkFileSize(uploadId2, null) + }) + + it('Should refuse an invalid digest', async function () { + const uploadId = await prepareUpload({ token: server.accessToken }) + + await sendChunks({ + pathUploadId: uploadId, + token: server.accessToken, + digestBuilder: () => 'sha=' + 'a'.repeat(40), + expectedStatus: 460 as any + }) + }) + + it('Should accept an appropriate digest', async function () { + const uploadId = await prepareUpload({ token: server.accessToken }) + + await sendChunks({ + pathUploadId: uploadId, + token: server.accessToken, + digestBuilder: (chunk: Buffer) => { + return 'sha1=' + sha1(chunk, 'base64') + } + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/single-server.ts b/packages/tests/src/api/videos/single-server.ts new file mode 100644 index 000000000..b87192a57 --- /dev/null +++ b/packages/tests/src/api/videos/single-server.ts @@ -0,0 +1,461 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { Video, VideoPrivacy } from '@peertube/peertube-models' +import { checkVideoFilesWereRemoved, completeVideoCheck } from '@tests/shared/videos.js' +import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js' +import { + cleanupTests, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test a single server', function () { + + function runSuite (mode: 'legacy' | 'resumable') { + let server: PeerTubeServer = null + let videoId: number | string + let videoId2: string + let videoUUID = '' + let videosListBase: any[] = null + + const getCheckAttributes = () => ({ + name: 'my super name', + category: 2, + licence: 6, + language: 'zh', + nsfw: true, + description: 'my super description', + support: 'my super support text', + account: { + name: 'root', + host: server.host + }, + isLocal: true, + duration: 5, + tags: [ 'tag1', 'tag2', 'tag3' ], + privacy: VideoPrivacy.PUBLIC, + commentsEnabled: true, + downloadEnabled: true, + channel: { + displayName: 'Main root channel', + name: 'root_channel', + description: '', + isLocal: true + }, + fixture: 'video_short.webm', + files: [ + { + resolution: 720, + size: 218910 + } + ] + }) + + const updateCheckAttributes = () => ({ + name: 'my super video updated', + category: 4, + licence: 2, + language: 'ar', + nsfw: false, + description: 'my super description updated', + support: 'my super support text updated', + account: { + name: 'root', + host: server.host + }, + isLocal: true, + tags: [ 'tagup1', 'tagup2' ], + privacy: VideoPrivacy.PUBLIC, + duration: 5, + commentsEnabled: false, + downloadEnabled: false, + channel: { + name: 'root_channel', + displayName: 'Main root channel', + description: '', + isLocal: true + }, + fixture: 'video_short3.webm', + files: [ + { + resolution: 720, + size: 292677 + } + ] + }) + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1, {}) + + await setAccessTokensToServers([ server ]) + await setDefaultChannelAvatar(server) + await setDefaultAccountAvatar(server) + }) + + it('Should list video categories', async function () { + const categories = await server.videos.getCategories() + expect(Object.keys(categories)).to.have.length.above(10) + + expect(categories[11]).to.equal('News & Politics') + }) + + it('Should list video licences', async function () { + const licences = await server.videos.getLicences() + expect(Object.keys(licences)).to.have.length.above(5) + + expect(licences[3]).to.equal('Attribution - No Derivatives') + }) + + it('Should list video languages', async function () { + const languages = await server.videos.getLanguages() + expect(Object.keys(languages)).to.have.length.above(5) + + expect(languages['ru']).to.equal('Russian') + }) + + it('Should list video privacies', async function () { + const privacies = await server.videos.getPrivacies() + expect(Object.keys(privacies)).to.have.length.at.least(3) + + expect(privacies[3]).to.equal('Private') + }) + + it('Should not have videos', async function () { + const { data, total } = await server.videos.list() + + expect(total).to.equal(0) + expect(data).to.be.an('array') + expect(data.length).to.equal(0) + }) + + it('Should upload the video', async function () { + const attributes = { + name: 'my super name', + category: 2, + nsfw: true, + licence: 6, + tags: [ 'tag1', 'tag2', 'tag3' ] + } + const video = await server.videos.upload({ attributes, mode }) + expect(video).to.not.be.undefined + expect(video.id).to.equal(1) + expect(video.uuid).to.have.length.above(5) + + videoId = video.id + videoUUID = video.uuid + }) + + it('Should get and seed the uploaded video', async function () { + this.timeout(5000) + + const { data, total } = await server.videos.list() + + expect(total).to.equal(1) + expect(data).to.be.an('array') + expect(data.length).to.equal(1) + + const video = data[0] + await completeVideoCheck({ server, originServer: server, videoUUID: video.uuid, attributes: getCheckAttributes() }) + }) + + it('Should get the video by UUID', async function () { + this.timeout(5000) + + const video = await server.videos.get({ id: videoUUID }) + await completeVideoCheck({ server, originServer: server, videoUUID: video.uuid, attributes: getCheckAttributes() }) + }) + + it('Should have the views updated', async function () { + this.timeout(20000) + + await server.views.simulateView({ id: videoId }) + await server.views.simulateView({ id: videoId }) + await server.views.simulateView({ id: videoId }) + + await wait(1500) + + await server.views.simulateView({ id: videoId }) + await server.views.simulateView({ id: videoId }) + + await wait(1500) + + await server.views.simulateView({ id: videoId }) + await server.views.simulateView({ id: videoId }) + + await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } }) + + const video = await server.videos.get({ id: videoId }) + expect(video.views).to.equal(3) + }) + + it('Should remove the video', async function () { + const video = await server.videos.get({ id: videoId }) + await server.videos.remove({ id: videoId }) + + await checkVideoFilesWereRemoved({ video, server }) + }) + + it('Should not have videos', async function () { + const { total, data } = await server.videos.list() + + expect(total).to.equal(0) + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(0) + }) + + it('Should upload 6 videos', async function () { + this.timeout(120000) + + const videos = new Set([ + 'video_short.mp4', 'video_short.ogv', 'video_short.webm', + 'video_short1.webm', 'video_short2.webm', 'video_short3.webm' + ]) + + for (const video of videos) { + const attributes = { + name: video + ' name', + description: video + ' description', + category: 2, + licence: 1, + language: 'en', + nsfw: true, + tags: [ 'tag1', 'tag2', 'tag3' ], + fixture: video + } + + await server.videos.upload({ attributes, mode }) + } + }) + + it('Should have the correct durations', async function () { + const { total, data } = await server.videos.list() + + expect(total).to.equal(6) + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(6) + + const videosByName: { [ name: string ]: Video } = {} + data.forEach(v => { videosByName[v.name] = v }) + + expect(videosByName['video_short.mp4 name'].duration).to.equal(5) + expect(videosByName['video_short.ogv name'].duration).to.equal(5) + expect(videosByName['video_short.webm name'].duration).to.equal(5) + expect(videosByName['video_short1.webm name'].duration).to.equal(10) + expect(videosByName['video_short2.webm name'].duration).to.equal(5) + expect(videosByName['video_short3.webm name'].duration).to.equal(5) + }) + + it('Should have the correct thumbnails', async function () { + const { data } = await server.videos.list() + + // For the next test + videosListBase = data + + for (const video of data) { + const videoName = video.name.replace(' name', '') + await testImageGeneratedByFFmpeg(server.url, videoName, video.thumbnailPath) + } + }) + + it('Should list only the two first videos', async function () { + const { total, data } = await server.videos.list({ start: 0, count: 2, sort: 'name' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(2) + expect(data[0].name).to.equal(videosListBase[0].name) + expect(data[1].name).to.equal(videosListBase[1].name) + }) + + it('Should list only the next three videos', async function () { + const { total, data } = await server.videos.list({ start: 2, count: 3, sort: 'name' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(3) + expect(data[0].name).to.equal(videosListBase[2].name) + expect(data[1].name).to.equal(videosListBase[3].name) + expect(data[2].name).to.equal(videosListBase[4].name) + }) + + it('Should list the last video', async function () { + const { total, data } = await server.videos.list({ start: 5, count: 6, sort: 'name' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(1) + expect(data[0].name).to.equal(videosListBase[5].name) + }) + + it('Should not have the total field', async function () { + const { total, data } = await server.videos.list({ start: 5, count: 6, sort: 'name', skipCount: true }) + + expect(total).to.not.exist + expect(data.length).to.equal(1) + expect(data[0].name).to.equal(videosListBase[5].name) + }) + + it('Should list and sort by name in descending order', async function () { + const { total, data } = await server.videos.list({ sort: '-name' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(6) + expect(data[0].name).to.equal('video_short.webm name') + expect(data[1].name).to.equal('video_short.ogv name') + expect(data[2].name).to.equal('video_short.mp4 name') + expect(data[3].name).to.equal('video_short3.webm name') + expect(data[4].name).to.equal('video_short2.webm name') + expect(data[5].name).to.equal('video_short1.webm name') + + videoId = data[3].uuid + videoId2 = data[5].uuid + }) + + it('Should list and sort by trending in descending order', async function () { + const { total, data } = await server.videos.list({ start: 0, count: 2, sort: '-trending' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(2) + }) + + it('Should list and sort by hotness in descending order', async function () { + const { total, data } = await server.videos.list({ start: 0, count: 2, sort: '-hot' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(2) + }) + + it('Should list and sort by best in descending order', async function () { + const { total, data } = await server.videos.list({ start: 0, count: 2, sort: '-best' }) + + expect(total).to.equal(6) + expect(data.length).to.equal(2) + }) + + it('Should update a video', async function () { + const attributes = { + name: 'my super video updated', + category: 4, + licence: 2, + language: 'ar', + nsfw: false, + description: 'my super description updated', + commentsEnabled: false, + downloadEnabled: false, + tags: [ 'tagup1', 'tagup2' ] + } + await server.videos.update({ id: videoId, attributes }) + }) + + it('Should have the video updated', async function () { + this.timeout(60000) + + await waitJobs([ server ]) + + const video = await server.videos.get({ id: videoId }) + + await completeVideoCheck({ server, originServer: server, videoUUID: video.uuid, attributes: updateCheckAttributes() }) + }) + + it('Should update only the tags of a video', async function () { + const attributes = { + tags: [ 'supertag', 'tag1', 'tag2' ] + } + await server.videos.update({ id: videoId, attributes }) + + const video = await server.videos.get({ id: videoId }) + + await completeVideoCheck({ + server, + originServer: server, + videoUUID: video.uuid, + attributes: Object.assign(updateCheckAttributes(), attributes) + }) + }) + + it('Should update only the description of a video', async function () { + const attributes = { + description: 'hello everybody' + } + await server.videos.update({ id: videoId, attributes }) + + const video = await server.videos.get({ id: videoId }) + + await completeVideoCheck({ + server, + originServer: server, + videoUUID: video.uuid, + attributes: Object.assign(updateCheckAttributes(), { tags: [ 'supertag', 'tag1', 'tag2' ] }, attributes) + }) + }) + + it('Should like a video', async function () { + await server.videos.rate({ id: videoId, rating: 'like' }) + + const video = await server.videos.get({ id: videoId }) + + expect(video.likes).to.equal(1) + expect(video.dislikes).to.equal(0) + }) + + it('Should dislike the same video', async function () { + await server.videos.rate({ id: videoId, rating: 'dislike' }) + + const video = await server.videos.get({ id: videoId }) + + expect(video.likes).to.equal(0) + expect(video.dislikes).to.equal(1) + }) + + it('Should sort by originallyPublishedAt', async function () { + { + const now = new Date() + const attributes = { originallyPublishedAt: now.toISOString() } + await server.videos.update({ id: videoId, attributes }) + + const { data } = await server.videos.list({ sort: '-originallyPublishedAt' }) + const names = data.map(v => v.name) + + expect(names[0]).to.equal('my super video updated') + expect(names[1]).to.equal('video_short2.webm name') + expect(names[2]).to.equal('video_short1.webm name') + expect(names[3]).to.equal('video_short.webm name') + expect(names[4]).to.equal('video_short.ogv name') + expect(names[5]).to.equal('video_short.mp4 name') + } + + { + const now = new Date() + const attributes = { originallyPublishedAt: now.toISOString() } + await server.videos.update({ id: videoId2, attributes }) + + const { data } = await server.videos.list({ sort: '-originallyPublishedAt' }) + const names = data.map(v => v.name) + + expect(names[0]).to.equal('video_short1.webm name') + expect(names[1]).to.equal('my super video updated') + expect(names[2]).to.equal('video_short2.webm name') + expect(names[3]).to.equal('video_short.webm name') + expect(names[4]).to.equal('video_short.ogv name') + expect(names[5]).to.equal('video_short.mp4 name') + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) + } + + describe('Legacy upload', function () { + runSuite('legacy') + }) + + describe('Resumable upload', function () { + runSuite('resumable') + }) +}) diff --git a/packages/tests/src/api/videos/video-captions.ts b/packages/tests/src/api/videos/video-captions.ts new file mode 100644 index 000000000..027022549 --- /dev/null +++ b/packages/tests/src/api/videos/video-captions.ts @@ -0,0 +1,189 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { testCaptionFile } from '@tests/shared/captions.js' +import { checkVideoFilesWereRemoved } from '@tests/shared/videos.js' + +describe('Test video captions', function () { + const uuidRegex = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' + + let servers: PeerTubeServer[] + let videoUUID: string + + before(async function () { + this.timeout(60000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await doubleFollow(servers[0], servers[1]) + + await waitJobs(servers) + + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video name' } }) + videoUUID = uuid + + await waitJobs(servers) + }) + + it('Should list the captions and return an empty list', async function () { + for (const server of servers) { + const body = await server.captions.list({ videoId: videoUUID }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + }) + + it('Should create two new captions', async function () { + this.timeout(30000) + + await servers[0].captions.add({ + language: 'ar', + videoId: videoUUID, + fixture: 'subtitle-good1.vtt' + }) + + await servers[0].captions.add({ + language: 'zh', + videoId: videoUUID, + fixture: 'subtitle-good2.vtt', + mimeType: 'application/octet-stream' + }) + + await waitJobs(servers) + }) + + it('Should list these uploaded captions', async function () { + for (const server of servers) { + const body = await server.captions.list({ videoId: videoUUID }) + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(2) + + const caption1 = body.data[0] + expect(caption1.language.id).to.equal('ar') + expect(caption1.language.label).to.equal('Arabic') + expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-ar.vtt$')) + await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 1.') + + const caption2 = body.data[1] + expect(caption2.language.id).to.equal('zh') + expect(caption2.language.label).to.equal('Chinese') + expect(caption2.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-zh.vtt$')) + await testCaptionFile(server.url, caption2.captionPath, 'Subtitle good 2.') + } + }) + + it('Should replace an existing caption', async function () { + this.timeout(30000) + + await servers[0].captions.add({ + language: 'ar', + videoId: videoUUID, + fixture: 'subtitle-good2.vtt' + }) + + await waitJobs(servers) + }) + + it('Should have this caption updated', async function () { + for (const server of servers) { + const body = await server.captions.list({ videoId: videoUUID }) + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(2) + + const caption1 = body.data[0] + expect(caption1.language.id).to.equal('ar') + expect(caption1.language.label).to.equal('Arabic') + expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-ar.vtt$')) + await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 2.') + } + }) + + it('Should replace an existing caption with a srt file and convert it', async function () { + this.timeout(30000) + + await servers[0].captions.add({ + language: 'ar', + videoId: videoUUID, + fixture: 'subtitle-good.srt' + }) + + await waitJobs(servers) + + // Cache invalidation + await wait(3000) + }) + + it('Should have this caption updated and converted', async function () { + for (const server of servers) { + const body = await server.captions.list({ videoId: videoUUID }) + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(2) + + const caption1 = body.data[0] + expect(caption1.language.id).to.equal('ar') + expect(caption1.language.label).to.equal('Arabic') + expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-ar.vtt$')) + + const expected = 'WEBVTT FILE\r\n' + + '\r\n' + + '1\r\n' + + '00:00:01.600 --> 00:00:04.200\r\n' + + 'English (US)\r\n' + + '\r\n' + + '2\r\n' + + '00:00:05.900 --> 00:00:07.999\r\n' + + 'This is a subtitle in American English\r\n' + + '\r\n' + + '3\r\n' + + '00:00:10.000 --> 00:00:14.000\r\n' + + 'Adding subtitles is very easy to do\r\n' + await testCaptionFile(server.url, caption1.captionPath, expected) + } + }) + + it('Should remove one caption', async function () { + this.timeout(30000) + + await servers[0].captions.delete({ videoId: videoUUID, language: 'ar' }) + + await waitJobs(servers) + }) + + it('Should only list the caption that was not deleted', async function () { + for (const server of servers) { + const body = await server.captions.list({ videoId: videoUUID }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const caption = body.data[0] + + expect(caption.language.id).to.equal('zh') + expect(caption.language.label).to.equal('Chinese') + expect(caption.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-zh.vtt$')) + await testCaptionFile(server.url, caption.captionPath, 'Subtitle good 2.') + } + }) + + it('Should remove the video, and thus all video captions', async function () { + const video = await servers[0].videos.get({ id: videoUUID }) + const { data: captions } = await servers[0].captions.list({ videoId: videoUUID }) + + await servers[0].videos.remove({ id: videoUUID }) + + await checkVideoFilesWereRemoved({ server: servers[0], video, captions }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-change-ownership.ts b/packages/tests/src/api/videos/video-change-ownership.ts new file mode 100644 index 000000000..717c37469 --- /dev/null +++ b/packages/tests/src/api/videos/video-change-ownership.ts @@ -0,0 +1,314 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + ChangeOwnershipCommand, + cleanupTests, + createMultipleServers, + createSingleServer, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' + +describe('Test video change ownership - nominal', function () { + let servers: PeerTubeServer[] = [] + + const firstUser = 'first' + const secondUser = 'second' + + let firstUserToken = '' + let firstUserChannelId: number + + let secondUserToken = '' + let secondUserChannelId: number + + let lastRequestId: number + + let liveId: number + + let command: ChangeOwnershipCommand + + before(async function () { + this.timeout(240000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await servers[0].config.updateCustomSubConfig({ + newConfig: { + transcoding: { + enabled: false + }, + live: { + enabled: true + } + } + }) + + firstUserToken = await servers[0].users.generateUserAndToken(firstUser) + secondUserToken = await servers[0].users.generateUserAndToken(secondUser) + + { + const { videoChannels } = await servers[0].users.getMyInfo({ token: firstUserToken }) + firstUserChannelId = videoChannels[0].id + } + + { + const { videoChannels } = await servers[0].users.getMyInfo({ token: secondUserToken }) + secondUserChannelId = videoChannels[0].id + } + + { + const attributes = { + name: 'my super name', + description: 'my super description' + } + const { id } = await servers[0].videos.upload({ token: firstUserToken, attributes }) + + servers[0].store.videoCreated = await servers[0].videos.get({ id }) + } + + { + const attributes = { name: 'live', channelId: firstUserChannelId, privacy: VideoPrivacy.PUBLIC } + const video = await servers[0].live.create({ token: firstUserToken, fields: attributes }) + + liveId = video.id + } + + command = servers[0].changeOwnership + + await doubleFollow(servers[0], servers[1]) + }) + + it('Should not have video change ownership', async function () { + { + const body = await command.list({ token: firstUserToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(0) + } + + { + const body = await command.list({ token: secondUserToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(0) + } + }) + + it('Should send a request to change ownership of a video', async function () { + this.timeout(15000) + + await command.create({ token: firstUserToken, videoId: servers[0].store.videoCreated.id, username: secondUser }) + }) + + it('Should only return a request to change ownership for the second user', async function () { + { + const body = await command.list({ token: firstUserToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(0) + } + + { + const body = await command.list({ token: secondUserToken }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(1) + + lastRequestId = body.data[0].id + } + }) + + it('Should accept the same change ownership request without crashing', async function () { + await command.create({ token: firstUserToken, videoId: servers[0].store.videoCreated.id, username: secondUser }) + }) + + it('Should not create multiple change ownership requests while one is waiting', async function () { + const body = await command.list({ token: secondUserToken }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(1) + }) + + it('Should not be possible to refuse the change of ownership from first user', async function () { + await command.refuse({ token: firstUserToken, ownershipId: lastRequestId, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should be possible to refuse the change of ownership from second user', async function () { + await command.refuse({ token: secondUserToken, ownershipId: lastRequestId }) + }) + + it('Should send a new request to change ownership of a video', async function () { + this.timeout(15000) + + await command.create({ token: firstUserToken, videoId: servers[0].store.videoCreated.id, username: secondUser }) + }) + + it('Should return two requests to change ownership for the second user', async function () { + { + const body = await command.list({ token: firstUserToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(0) + } + + { + const body = await command.list({ token: secondUserToken }) + + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(2) + + lastRequestId = body.data[0].id + } + }) + + it('Should not be possible to accept the change of ownership from first user', async function () { + await command.accept({ + token: firstUserToken, + ownershipId: lastRequestId, + channelId: secondUserChannelId, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should be possible to accept the change of ownership from second user', async function () { + await command.accept({ token: secondUserToken, ownershipId: lastRequestId, channelId: secondUserChannelId }) + + await waitJobs(servers) + }) + + it('Should have the channel of the video updated', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: servers[0].store.videoCreated.uuid }) + + expect(video.name).to.equal('my super name') + expect(video.channel.displayName).to.equal('Main second channel') + expect(video.channel.name).to.equal('second_channel') + } + }) + + it('Should send a request to change ownership of a live', async function () { + this.timeout(15000) + + await command.create({ token: firstUserToken, videoId: liveId, username: secondUser }) + + const body = await command.list({ token: secondUserToken }) + + expect(body.total).to.equal(3) + expect(body.data.length).to.equal(3) + + lastRequestId = body.data[0].id + }) + + it('Should accept a live ownership change', async function () { + this.timeout(20000) + + await command.accept({ token: secondUserToken, ownershipId: lastRequestId, channelId: secondUserChannelId }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: servers[0].store.videoCreated.uuid }) + + expect(video.name).to.equal('my super name') + expect(video.channel.displayName).to.equal('Main second channel') + expect(video.channel.name).to.equal('second_channel') + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) + +describe('Test video change ownership - quota too small', function () { + let server: PeerTubeServer + const firstUser = 'first' + const secondUser = 'second' + + let firstUserToken = '' + let secondUserToken = '' + let lastRequestId: number + + before(async function () { + this.timeout(50000) + + // Run one server + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + + await server.users.create({ username: secondUser, videoQuota: 10 }) + + firstUserToken = await server.users.generateUserAndToken(firstUser) + secondUserToken = await server.login.getAccessToken(secondUser) + + // Upload some videos on the server + const attributes = { + name: 'my super name', + description: 'my super description' + } + await server.videos.upload({ token: firstUserToken, attributes }) + + await waitJobs(server) + + const { data } = await server.videos.list() + expect(data.length).to.equal(1) + + server.store.videoCreated = data.find(video => video.name === 'my super name') + }) + + it('Should send a request to change ownership of a video', async function () { + this.timeout(15000) + + await server.changeOwnership.create({ token: firstUserToken, videoId: server.store.videoCreated.id, username: secondUser }) + }) + + it('Should only return a request to change ownership for the second user', async function () { + { + const body = await server.changeOwnership.list({ token: firstUserToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(0) + } + + { + const body = await server.changeOwnership.list({ token: secondUserToken }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data.length).to.equal(1) + + lastRequestId = body.data[0].id + } + }) + + it('Should not be possible to accept the change of ownership from second user because of exceeded quota', async function () { + const { videoChannels } = await server.users.getMyInfo({ token: secondUserToken }) + const channelId = videoChannels[0].id + + await server.changeOwnership.accept({ + token: secondUserToken, + ownershipId: lastRequestId, + channelId, + expectedStatus: HttpStatusCode.PAYLOAD_TOO_LARGE_413 + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/video-channel-syncs.ts b/packages/tests/src/api/videos/video-channel-syncs.ts new file mode 100644 index 000000000..54212bcb5 --- /dev/null +++ b/packages/tests/src/api/videos/video-channel-syncs.ts @@ -0,0 +1,321 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils' +import { VideoChannelSyncState, VideoInclude, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + getServerImportConfig, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { FIXTURE_URLS } from '@tests/shared/tests.js' + +describe('Test channel synchronizations', function () { + if (areHttpImportTestsDisabled()) return + + function runSuite (mode: 'youtube-dl' | 'yt-dlp') { + + describe('Sync using ' + mode, function () { + let servers: PeerTubeServer[] + let sqlCommands: SQLCommand[] = [] + + let startTestDate: Date + + let rootChannelSyncId: number + const userInfo = { + accessToken: '', + username: 'user1', + channelName: 'user1_channel', + channelId: -1, + syncId: -1 + } + + async function changeDateForSync (channelSyncId: number, newDate: string) { + await sqlCommands[0].updateQuery( + `UPDATE "videoChannelSync" ` + + `SET "createdAt"='${newDate}', "lastSyncAt"='${newDate}' ` + + `WHERE id=${channelSyncId}` + ) + } + + async function listAllVideosOfChannel (channelName: string) { + return servers[0].videos.listByChannel({ + handle: channelName, + include: VideoInclude.NOT_PUBLISHED_STATE + }) + } + + async function forceSyncAll (videoChannelSyncId: number, fromDate = '1970-01-01') { + await changeDateForSync(videoChannelSyncId, fromDate) + + await servers[0].debug.sendCommand({ + body: { + command: 'process-video-channel-sync-latest' + } + }) + + await waitJobs(servers) + } + + before(async function () { + this.timeout(240_000) + + startTestDate = new Date() + + servers = await createMultipleServers(2, getServerImportConfig(mode)) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultChannelAvatar(servers) + await setDefaultAccountAvatar(servers) + + await servers[0].config.enableChannelSync() + + { + userInfo.accessToken = await servers[0].users.generateUserAndToken(userInfo.username) + + const { videoChannels } = await servers[0].users.getMyInfo({ token: userInfo.accessToken }) + userInfo.channelId = videoChannels[0].id + } + + sqlCommands = servers.map(s => new SQLCommand(s)) + }) + + it('Should fetch the latest channel videos of a remote channel', async function () { + this.timeout(120_000) + + { + const { video } = await servers[0].imports.importVideo({ + attributes: { + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + targetUrl: FIXTURE_URLS.youtube + } + }) + + expect(video.name).to.equal('small video - youtube') + expect(video.waitTranscoding).to.be.true + + const { total } = await listAllVideosOfChannel('root_channel') + expect(total).to.equal(1) + } + + const { videoChannelSync } = await servers[0].channelSyncs.create({ + attributes: { + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + videoChannelId: servers[0].store.channel.id + } + }) + rootChannelSyncId = videoChannelSync.id + + await forceSyncAll(rootChannelSyncId) + + { + const { total, data } = await listAllVideosOfChannel('root_channel') + expect(total).to.equal(2) + expect(data[0].name).to.equal('test') + expect(data[0].waitTranscoding).to.be.true + } + }) + + it('Should add another synchronization', async function () { + const externalChannelUrl = FIXTURE_URLS.youtubeChannel + '?foo=bar' + + const { videoChannelSync } = await servers[0].channelSyncs.create({ + attributes: { + externalChannelUrl, + videoChannelId: servers[0].store.channel.id + } + }) + + expect(videoChannelSync.externalChannelUrl).to.equal(externalChannelUrl) + expect(videoChannelSync.channel.id).to.equal(servers[0].store.channel.id) + expect(videoChannelSync.channel.name).to.equal('root_channel') + expect(videoChannelSync.state.id).to.equal(VideoChannelSyncState.WAITING_FIRST_RUN) + expect(new Date(videoChannelSync.createdAt)).to.be.above(startTestDate).and.to.be.at.most(new Date()) + }) + + it('Should add a synchronization for another user', async function () { + const { videoChannelSync } = await servers[0].channelSyncs.create({ + attributes: { + externalChannelUrl: FIXTURE_URLS.youtubeChannel + '?baz=qux', + videoChannelId: userInfo.channelId + }, + token: userInfo.accessToken + }) + userInfo.syncId = videoChannelSync.id + }) + + it('Should not import a channel if not asked', async function () { + await waitJobs(servers) + + const { data } = await servers[0].channelSyncs.listByAccount({ accountName: userInfo.username }) + + expect(data[0].state).to.contain({ + id: VideoChannelSyncState.WAITING_FIRST_RUN, + label: 'Waiting first run' + }) + }) + + it('Should only fetch the videos newer than the creation date', async function () { + this.timeout(120_000) + + await forceSyncAll(userInfo.syncId, '2019-03-01') + + const { data, total } = await listAllVideosOfChannel(userInfo.channelName) + + expect(total).to.equal(1) + expect(data[0].name).to.equal('test') + }) + + it('Should list channel synchronizations', async function () { + // Root + { + const { total, data } = await servers[0].channelSyncs.listByAccount({ accountName: 'root' }) + expect(total).to.equal(2) + + expect(data[0]).to.deep.contain({ + externalChannelUrl: FIXTURE_URLS.youtubeChannel, + state: { + id: VideoChannelSyncState.SYNCED, + label: 'Synchronized' + } + }) + + expect(new Date(data[0].lastSyncAt)).to.be.greaterThan(startTestDate) + + expect(data[0].channel).to.contain({ id: servers[0].store.channel.id }) + expect(data[1]).to.contain({ externalChannelUrl: FIXTURE_URLS.youtubeChannel + '?foo=bar' }) + } + + // User + { + const { total, data } = await servers[0].channelSyncs.listByAccount({ accountName: userInfo.username }) + expect(total).to.equal(1) + expect(data[0]).to.deep.contain({ + externalChannelUrl: FIXTURE_URLS.youtubeChannel + '?baz=qux', + state: { + id: VideoChannelSyncState.SYNCED, + label: 'Synchronized' + } + }) + } + }) + + it('Should list imports of a channel synchronization', async function () { + const { total, data } = await servers[0].imports.getMyVideoImports({ videoChannelSyncId: rootChannelSyncId }) + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + expect(data[0].video.name).to.equal('test') + }) + + it('Should remove user\'s channel synchronizations', async function () { + await servers[0].channelSyncs.delete({ channelSyncId: userInfo.syncId }) + + const { total } = await servers[0].channelSyncs.listByAccount({ accountName: userInfo.username }) + expect(total).to.equal(0) + }) + + // FIXME: youtube-dl/yt-dlp doesn't work when speicifying a port after the hostname + // it('Should import a remote PeerTube channel', async function () { + // this.timeout(240_000) + + // await servers[1].videos.quickUpload({ name: 'remote 1' }) + // await waitJobs(servers) + + // const { videoChannelSync } = await servers[0].channelSyncs.create({ + // attributes: { + // externalChannelUrl: servers[1].url + '/c/root_channel', + // videoChannelId: userInfo.channelId + // }, + // token: userInfo.accessToken + // }) + // await servers[0].channels.importVideos({ + // channelName: userInfo.channelName, + // externalChannelUrl: servers[1].url + '/c/root_channel', + // videoChannelSyncId: videoChannelSync.id, + // token: userInfo.accessToken + // }) + + // await waitJobs(servers) + + // const { data, total } = await servers[0].videos.listByChannel({ + // handle: userInfo.channelName, + // include: VideoInclude.NOT_PUBLISHED_STATE + // }) + + // expect(total).to.equal(2) + // expect(data[0].name).to.equal('remote 1') + // }) + + // it('Should keep synced a remote PeerTube channel', async function () { + // this.timeout(240_000) + + // await servers[1].videos.quickUpload({ name: 'remote 2' }) + // await waitJobs(servers) + + // await servers[0].debug.sendCommand({ + // body: { + // command: 'process-video-channel-sync-latest' + // } + // }) + + // await waitJobs(servers) + + // const { data, total } = await servers[0].videos.listByChannel({ + // handle: userInfo.channelName, + // include: VideoInclude.NOT_PUBLISHED_STATE + // }) + // expect(total).to.equal(2) + // expect(data[0].name).to.equal('remote 2') + // }) + + it('Should fetch the latest videos of a youtube playlist', async function () { + this.timeout(120_000) + + const { id: channelId } = await servers[0].channels.create({ + attributes: { + name: 'channel2' + } + }) + + const { videoChannelSync: { id: videoChannelSyncId } } = await servers[0].channelSyncs.create({ + attributes: { + externalChannelUrl: FIXTURE_URLS.youtubePlaylist, + videoChannelId: channelId + } + }) + + await forceSyncAll(videoChannelSyncId) + + { + + const { total, data } = await listAllVideosOfChannel('channel2') + expect(total).to.equal(2) + expect(data[0].name).to.equal('test') + expect(data[1].name).to.equal('small video - youtube') + } + }) + + after(async function () { + for (const sqlCommand of sqlCommands) { + await sqlCommand.cleanup() + } + + await cleanupTests(servers) + }) + }) + } + + // FIXME: suite is broken with youtube-dl + // runSuite('youtube-dl') + runSuite('yt-dlp') +}) diff --git a/packages/tests/src/api/videos/video-channels.ts b/packages/tests/src/api/videos/video-channels.ts new file mode 100644 index 000000000..64b1b9315 --- /dev/null +++ b/packages/tests/src/api/videos/video-channels.ts @@ -0,0 +1,556 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { basename } from 'path' +import { ACTOR_IMAGES_SIZE } from '@peertube/peertube-server/server/initializers/constants.js' +import { testFileExistsOrNot, testImage } from '@tests/shared/checks.js' +import { SQLCommand } from '@tests/shared/sql-command.js' +import { wait } from '@peertube/peertube-core-utils' +import { ActorImageType, User, VideoChannel } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +async function findChannel (server: PeerTubeServer, channelId: number) { + const body = await server.channels.list({ sort: '-name' }) + + return body.data.find(c => c.id === channelId) +} + +describe('Test video channels', function () { + let servers: PeerTubeServer[] + let sqlCommands: SQLCommand[] = [] + + let userInfo: User + let secondVideoChannelId: number + let totoChannel: number + let videoUUID: string + let accountName: string + let secondUserChannelName: string + + const avatarPaths: { [ port: number ]: string } = {} + const bannerPaths: { [ port: number ]: string } = {} + + before(async function () { + this.timeout(60000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultAccountAvatar(servers) + + await doubleFollow(servers[0], servers[1]) + + sqlCommands = servers.map(s => new SQLCommand(s)) + }) + + it('Should have one video channel (created with root)', async () => { + const body = await servers[0].channels.list({ start: 0, count: 2 }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + }) + + it('Should create another video channel', async function () { + this.timeout(30000) + + { + const videoChannel = { + name: 'second_video_channel', + displayName: 'second video channel', + description: 'super video channel description', + support: 'super video channel support text' + } + const created = await servers[0].channels.create({ attributes: videoChannel }) + secondVideoChannelId = created.id + } + + // The channel is 1 is propagated to servers 2 + { + const attributes = { name: 'my video name', channelId: secondVideoChannelId, support: 'video support field' } + const { uuid } = await servers[0].videos.upload({ attributes }) + videoUUID = uuid + } + + await waitJobs(servers) + }) + + it('Should have two video channels when getting my information', async () => { + userInfo = await servers[0].users.getMyInfo() + + expect(userInfo.videoChannels).to.be.an('array') + expect(userInfo.videoChannels).to.have.lengthOf(2) + + const videoChannels = userInfo.videoChannels + expect(videoChannels[0].name).to.equal('root_channel') + expect(videoChannels[0].displayName).to.equal('Main root channel') + + expect(videoChannels[1].name).to.equal('second_video_channel') + expect(videoChannels[1].displayName).to.equal('second video channel') + expect(videoChannels[1].description).to.equal('super video channel description') + expect(videoChannels[1].support).to.equal('super video channel support text') + + accountName = userInfo.account.name + '@' + userInfo.account.host + }) + + it('Should have two video channels when getting account channels on server 1', async function () { + const body = await servers[0].channels.listByAccount({ accountName }) + expect(body.total).to.equal(2) + + const videoChannels = body.data + + expect(videoChannels).to.be.an('array') + expect(videoChannels).to.have.lengthOf(2) + + expect(videoChannels[0].name).to.equal('root_channel') + expect(videoChannels[0].displayName).to.equal('Main root channel') + + expect(videoChannels[1].name).to.equal('second_video_channel') + expect(videoChannels[1].displayName).to.equal('second video channel') + expect(videoChannels[1].description).to.equal('super video channel description') + expect(videoChannels[1].support).to.equal('super video channel support text') + }) + + it('Should paginate and sort account channels', async function () { + { + const body = await servers[0].channels.listByAccount({ + accountName, + start: 0, + count: 1, + sort: 'createdAt' + }) + + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(1) + + const videoChannel: VideoChannel = body.data[0] + expect(videoChannel.name).to.equal('root_channel') + } + + { + const body = await servers[0].channels.listByAccount({ + accountName, + start: 0, + count: 1, + sort: '-createdAt' + }) + + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('second_video_channel') + } + + { + const body = await servers[0].channels.listByAccount({ + accountName, + start: 1, + count: 1, + sort: '-createdAt' + }) + + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('root_channel') + } + }) + + it('Should have one video channel when getting account channels on server 2', async function () { + const body = await servers[1].channels.listByAccount({ accountName }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + + const videoChannel = body.data[0] + expect(videoChannel.name).to.equal('second_video_channel') + expect(videoChannel.displayName).to.equal('second video channel') + expect(videoChannel.description).to.equal('super video channel description') + expect(videoChannel.support).to.equal('super video channel support text') + }) + + it('Should list video channels', async function () { + const body = await servers[0].channels.list({ start: 1, count: 1, sort: '-name' }) + + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('root_channel') + expect(body.data[0].displayName).to.equal('Main root channel') + }) + + it('Should update video channel', async function () { + this.timeout(15000) + + const videoChannelAttributes = { + displayName: 'video channel updated', + description: 'video channel description updated', + support: 'support updated' + } + + await servers[0].channels.update({ channelName: 'second_video_channel', attributes: videoChannelAttributes }) + + await waitJobs(servers) + }) + + it('Should have video channel updated', async function () { + for (const server of servers) { + const body = await server.channels.list({ start: 0, count: 1, sort: '-name' }) + + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + + expect(body.data[0].name).to.equal('second_video_channel') + expect(body.data[0].displayName).to.equal('video channel updated') + expect(body.data[0].description).to.equal('video channel description updated') + expect(body.data[0].support).to.equal('support updated') + } + }) + + it('Should not have updated the video support field', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(video.support).to.equal('video support field') + } + }) + + it('Should update another accounts video channel', async function () { + this.timeout(15000) + + const result = await servers[0].users.generate('second_user') + secondUserChannelName = result.userChannelName + + await servers[0].videos.quickUpload({ name: 'video', token: result.token }) + + const videoChannelAttributes = { + displayName: 'video channel updated', + description: 'video channel description updated', + support: 'support updated' + } + + await servers[0].channels.update({ channelName: secondUserChannelName, attributes: videoChannelAttributes }) + + await waitJobs(servers) + }) + + it('Should have another accounts video channel updated', async function () { + for (const server of servers) { + const body = await server.channels.get({ channelName: `${secondUserChannelName}@${servers[0].host}` }) + + expect(body.displayName).to.equal('video channel updated') + expect(body.description).to.equal('video channel description updated') + expect(body.support).to.equal('support updated') + } + }) + + it('Should update the channel support field and update videos too', async function () { + this.timeout(35000) + + const videoChannelAttributes = { + support: 'video channel support text updated', + bulkVideosSupportUpdate: true + } + + await servers[0].channels.update({ channelName: 'second_video_channel', attributes: videoChannelAttributes }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + expect(video.support).to.equal(videoChannelAttributes.support) + } + }) + + it('Should update video channel avatar', async function () { + this.timeout(15000) + + const fixture = 'avatar.png' + + await servers[0].channels.updateImage({ + channelName: 'second_video_channel', + fixture, + type: 'avatar' + }) + + await waitJobs(servers) + + for (let i = 0; i < servers.length; i++) { + const server = servers[i] + + const videoChannel = await findChannel(server, secondVideoChannelId) + const expectedSizes = ACTOR_IMAGES_SIZE[ActorImageType.AVATAR] + + expect(videoChannel.avatars.length).to.equal(expectedSizes.length, 'Expected avatars to be generated in all sizes') + + for (const avatar of videoChannel.avatars) { + avatarPaths[server.port] = avatar.path + await testImage(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatarPaths[server.port], '.png') + await testFileExistsOrNot(server, 'avatars', basename(avatarPaths[server.port]), true) + + const row = await sqlCommands[i].getActorImage(basename(avatarPaths[server.port])) + + expect(expectedSizes.some(({ height, width }) => row.height === height && row.width === width)).to.equal(true) + } + } + }) + + it('Should update video channel banner', async function () { + this.timeout(15000) + + const fixture = 'banner.jpg' + + await servers[0].channels.updateImage({ + channelName: 'second_video_channel', + fixture, + type: 'banner' + }) + + await waitJobs(servers) + + for (let i = 0; i < servers.length; i++) { + const server = servers[i] + + const videoChannel = await server.channels.get({ channelName: 'second_video_channel@' + servers[0].host }) + + bannerPaths[server.port] = videoChannel.banners[0].path + await testImage(server.url, 'banner-resized', bannerPaths[server.port]) + await testFileExistsOrNot(server, 'avatars', basename(bannerPaths[server.port]), true) + + const row = await sqlCommands[i].getActorImage(basename(bannerPaths[server.port])) + expect(row.height).to.equal(ACTOR_IMAGES_SIZE[ActorImageType.BANNER][0].height) + expect(row.width).to.equal(ACTOR_IMAGES_SIZE[ActorImageType.BANNER][0].width) + } + }) + + it('Should still correctly list channels', async function () { + { + const body = await servers[0].channels.list({ start: 1, count: 1, sort: 'createdAt' }) + + expect(body.total).to.equal(3) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('second_video_channel') + } + + { + const body = await servers[0].channels.listByAccount({ accountName, start: 1, count: 1, sort: 'createdAt' }) + + expect(body.total).to.equal(2) + expect(body.data).to.have.lengthOf(1) + expect(body.data[0].name).to.equal('second_video_channel') + } + }) + + it('Should delete the video channel avatar', async function () { + this.timeout(15000) + await servers[0].channels.deleteImage({ channelName: 'second_video_channel', type: 'avatar' }) + + await waitJobs(servers) + + for (const server of servers) { + const videoChannel = await findChannel(server, secondVideoChannelId) + await testFileExistsOrNot(server, 'avatars', basename(avatarPaths[server.port]), false) + + expect(videoChannel.avatars).to.be.empty + } + }) + + it('Should delete the video channel banner', async function () { + this.timeout(15000) + + await servers[0].channels.deleteImage({ channelName: 'second_video_channel', type: 'banner' }) + + await waitJobs(servers) + + for (const server of servers) { + const videoChannel = await findChannel(server, secondVideoChannelId) + await testFileExistsOrNot(server, 'avatars', basename(bannerPaths[server.port]), false) + + expect(videoChannel.banners).to.be.empty + } + }) + + it('Should list the second video channel videos', async function () { + for (const server of servers) { + const channelURI = 'second_video_channel@' + servers[0].host + const { total, data } = await server.videos.listByChannel({ handle: channelURI }) + + expect(total).to.equal(1) + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('my video name') + } + }) + + it('Should change the video channel of a video', async function () { + await servers[0].videos.update({ id: videoUUID, attributes: { channelId: servers[0].store.channel.id } }) + + await waitJobs(servers) + }) + + it('Should list the first video channel videos', async function () { + for (const server of servers) { + { + const secondChannelURI = 'second_video_channel@' + servers[0].host + const { total } = await server.videos.listByChannel({ handle: secondChannelURI }) + expect(total).to.equal(0) + } + + { + const channelURI = 'root_channel@' + servers[0].host + const { total, data } = await server.videos.listByChannel({ handle: channelURI }) + expect(total).to.equal(1) + + expect(data).to.be.an('array') + expect(data).to.have.lengthOf(1) + expect(data[0].name).to.equal('my video name') + } + } + }) + + it('Should delete video channel', async function () { + await servers[0].channels.delete({ channelName: 'second_video_channel' }) + }) + + it('Should have video channel deleted', async function () { + const body = await servers[0].channels.list({ start: 0, count: 10, sort: 'createdAt' }) + + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + expect(body.data[0].displayName).to.equal('Main root channel') + expect(body.data[1].displayName).to.equal('video channel updated') + }) + + it('Should create the main channel with a suffix if there is a conflict', async function () { + { + const videoChannel = { name: 'toto_channel', displayName: 'My toto channel' } + const created = await servers[0].channels.create({ attributes: videoChannel }) + totoChannel = created.id + } + + { + await servers[0].users.create({ username: 'toto', password: 'password' }) + const accessToken = await servers[0].login.getAccessToken({ username: 'toto', password: 'password' }) + + const { videoChannels } = await servers[0].users.getMyInfo({ token: accessToken }) + expect(videoChannels[0].name).to.equal('toto_channel-1') + } + }) + + it('Should report correct channel views per days', async function () { + { + const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true }) + + for (const channel of data) { + expect(channel).to.haveOwnProperty('viewsPerDay') + expect(channel.viewsPerDay).to.have.length(30 + 1) // daysPrior + today + + for (const v of channel.viewsPerDay) { + expect(v.date).to.be.an('string') + expect(v.views).to.equal(0) + } + } + } + + { + // video has been posted on channel servers[0].store.videoChannel.id since last update + await servers[0].views.simulateView({ id: videoUUID, xForwardedFor: '0.0.0.1,127.0.0.1' }) + await servers[0].views.simulateView({ id: videoUUID, xForwardedFor: '0.0.0.2,127.0.0.1' }) + + // Wait the repeatable job + await wait(8000) + + const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true }) + const channelWithView = data.find(channel => channel.id === servers[0].store.channel.id) + expect(channelWithView.viewsPerDay.slice(-1)[0].views).to.equal(2) + } + }) + + it('Should report correct total views count', async function () { + // check if there's the property + { + const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true }) + + for (const channel of data) { + expect(channel).to.haveOwnProperty('totalViews') + expect(channel.totalViews).to.be.a('number') + } + } + + // Check if the totalViews count can be updated + { + const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true }) + const channelWithView = data.find(channel => channel.id === servers[0].store.channel.id) + expect(channelWithView.totalViews).to.equal(2) + } + }) + + it('Should report correct videos count', async function () { + const { data } = await servers[0].channels.listByAccount({ accountName, withStats: true }) + + const totoChannel = data.find(c => c.name === 'toto_channel') + const rootChannel = data.find(c => c.name === 'root_channel') + + expect(rootChannel.videosCount).to.equal(1) + expect(totoChannel.videosCount).to.equal(0) + }) + + it('Should search among account video channels', async function () { + { + const body = await servers[0].channels.listByAccount({ accountName, search: 'root' }) + expect(body.total).to.equal(1) + + const channels = body.data + expect(channels).to.have.lengthOf(1) + } + + { + const body = await servers[0].channels.listByAccount({ accountName, search: 'does not exist' }) + expect(body.total).to.equal(0) + + const channels = body.data + expect(channels).to.have.lengthOf(0) + } + }) + + it('Should list channels by updatedAt desc if a video has been uploaded', async function () { + this.timeout(30000) + + await servers[0].videos.upload({ attributes: { channelId: totoChannel } }) + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.channels.listByAccount({ accountName, sort: '-updatedAt' }) + + expect(data[0].name).to.equal('toto_channel') + expect(data[1].name).to.equal('root_channel') + } + + await servers[0].videos.upload({ attributes: { channelId: servers[0].store.channel.id } }) + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.channels.listByAccount({ accountName, sort: '-updatedAt' }) + + expect(data[0].name).to.equal('root_channel') + expect(data[1].name).to.equal('toto_channel') + } + }) + + after(async function () { + for (const sqlCommand of sqlCommands) { + await sqlCommand.cleanup() + } + + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-comments.ts b/packages/tests/src/api/videos/video-comments.ts new file mode 100644 index 000000000..f17db9979 --- /dev/null +++ b/packages/tests/src/api/videos/video-comments.ts @@ -0,0 +1,335 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { dateIsValid, testImage } from '@tests/shared/checks.js' +import { + cleanupTests, + CommentsCommand, + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar +} from '@peertube/peertube-server-commands' + +describe('Test video comments', function () { + let server: PeerTubeServer + let videoId: number + let videoUUID: string + let threadId: number + let replyToDeleteId: number + + let userAccessTokenServer1: string + + let command: CommentsCommand + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + const { id, uuid } = await server.videos.upload() + videoUUID = uuid + videoId = id + + await setDefaultChannelAvatar(server) + await setDefaultAccountAvatar(server) + + userAccessTokenServer1 = await server.users.generateUserAndToken('user1') + await setDefaultChannelAvatar(server, 'user1_channel') + await setDefaultAccountAvatar(server, userAccessTokenServer1) + + command = server.comments + }) + + describe('User comments', function () { + + it('Should not have threads on this video', async function () { + const body = await command.listThreads({ videoId: videoUUID }) + + expect(body.total).to.equal(0) + expect(body.totalNotDeletedComments).to.equal(0) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(0) + }) + + it('Should create a thread in this video', async function () { + const text = 'my super first comment' + + const comment = await command.createThread({ videoId: videoUUID, text }) + + expect(comment.inReplyToCommentId).to.be.null + expect(comment.text).equal('my super first comment') + expect(comment.videoId).to.equal(videoId) + expect(comment.id).to.equal(comment.threadId) + expect(comment.account.name).to.equal('root') + expect(comment.account.host).to.equal(server.host) + expect(comment.account.url).to.equal(server.url + '/accounts/root') + expect(comment.totalReplies).to.equal(0) + expect(comment.totalRepliesFromVideoAuthor).to.equal(0) + expect(dateIsValid(comment.createdAt as string)).to.be.true + expect(dateIsValid(comment.updatedAt as string)).to.be.true + }) + + it('Should list threads of this video', async function () { + const body = await command.listThreads({ videoId: videoUUID }) + + expect(body.total).to.equal(1) + expect(body.totalNotDeletedComments).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + + const comment = body.data[0] + expect(comment.inReplyToCommentId).to.be.null + expect(comment.text).equal('my super first comment') + expect(comment.videoId).to.equal(videoId) + expect(comment.id).to.equal(comment.threadId) + expect(comment.account.name).to.equal('root') + expect(comment.account.host).to.equal(server.host) + + for (const avatar of comment.account.avatars) { + await testImage(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png') + } + + expect(comment.totalReplies).to.equal(0) + expect(comment.totalRepliesFromVideoAuthor).to.equal(0) + expect(dateIsValid(comment.createdAt as string)).to.be.true + expect(dateIsValid(comment.updatedAt as string)).to.be.true + + threadId = comment.threadId + }) + + it('Should get all the thread created', async function () { + const body = await command.getThread({ videoId: videoUUID, threadId }) + + const rootComment = body.comment + expect(rootComment.inReplyToCommentId).to.be.null + expect(rootComment.text).equal('my super first comment') + expect(rootComment.videoId).to.equal(videoId) + expect(dateIsValid(rootComment.createdAt as string)).to.be.true + expect(dateIsValid(rootComment.updatedAt as string)).to.be.true + }) + + it('Should create multiple replies in this thread', async function () { + const text1 = 'my super answer to thread 1' + const created = await command.addReply({ videoId, toCommentId: threadId, text: text1 }) + const childCommentId = created.id + + const text2 = 'my super answer to answer of thread 1' + await command.addReply({ videoId, toCommentId: childCommentId, text: text2 }) + + const text3 = 'my second answer to thread 1' + await command.addReply({ videoId, toCommentId: threadId, text: text3 }) + }) + + it('Should get correctly the replies', async function () { + const tree = await command.getThread({ videoId: videoUUID, threadId }) + + expect(tree.comment.text).equal('my super first comment') + expect(tree.children).to.have.lengthOf(2) + + const firstChild = tree.children[0] + expect(firstChild.comment.text).to.equal('my super answer to thread 1') + expect(firstChild.children).to.have.lengthOf(1) + + const childOfFirstChild = firstChild.children[0] + expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1') + expect(childOfFirstChild.children).to.have.lengthOf(0) + + const secondChild = tree.children[1] + expect(secondChild.comment.text).to.equal('my second answer to thread 1') + expect(secondChild.children).to.have.lengthOf(0) + + replyToDeleteId = secondChild.comment.id + }) + + it('Should create other threads', async function () { + const text1 = 'super thread 2' + await command.createThread({ videoId: videoUUID, text: text1 }) + + const text2 = 'super thread 3' + await command.createThread({ videoId: videoUUID, text: text2 }) + }) + + it('Should list the threads', async function () { + const body = await command.listThreads({ videoId: videoUUID, sort: 'createdAt' }) + + expect(body.total).to.equal(3) + expect(body.totalNotDeletedComments).to.equal(6) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(3) + + expect(body.data[0].text).to.equal('my super first comment') + expect(body.data[0].totalReplies).to.equal(3) + expect(body.data[1].text).to.equal('super thread 2') + expect(body.data[1].totalReplies).to.equal(0) + expect(body.data[2].text).to.equal('super thread 3') + expect(body.data[2].totalReplies).to.equal(0) + }) + + it('Should list the and sort them by total replies', async function () { + const body = await command.listThreads({ videoId: videoUUID, sort: 'totalReplies' }) + + expect(body.data[2].text).to.equal('my super first comment') + expect(body.data[2].totalReplies).to.equal(3) + }) + + it('Should delete a reply', async function () { + await command.delete({ videoId, commentId: replyToDeleteId }) + + { + const body = await command.listThreads({ videoId: videoUUID, sort: 'createdAt' }) + + expect(body.total).to.equal(3) + expect(body.totalNotDeletedComments).to.equal(5) + } + + { + const tree = await command.getThread({ videoId: videoUUID, threadId }) + + expect(tree.comment.text).equal('my super first comment') + expect(tree.children).to.have.lengthOf(2) + + const firstChild = tree.children[0] + expect(firstChild.comment.text).to.equal('my super answer to thread 1') + expect(firstChild.children).to.have.lengthOf(1) + + const childOfFirstChild = firstChild.children[0] + expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1') + expect(childOfFirstChild.children).to.have.lengthOf(0) + + const deletedChildOfFirstChild = tree.children[1] + expect(deletedChildOfFirstChild.comment.text).to.equal('') + expect(deletedChildOfFirstChild.comment.isDeleted).to.be.true + expect(deletedChildOfFirstChild.comment.deletedAt).to.not.be.null + expect(deletedChildOfFirstChild.comment.account).to.be.null + expect(deletedChildOfFirstChild.children).to.have.lengthOf(0) + } + }) + + it('Should delete a complete thread', async function () { + await command.delete({ videoId, commentId: threadId }) + + const body = await command.listThreads({ videoId: videoUUID, sort: 'createdAt' }) + expect(body.total).to.equal(3) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(3) + + expect(body.data[0].text).to.equal('') + expect(body.data[0].isDeleted).to.be.true + expect(body.data[0].deletedAt).to.not.be.null + expect(body.data[0].account).to.be.null + expect(body.data[0].totalReplies).to.equal(2) + expect(body.data[1].text).to.equal('super thread 2') + expect(body.data[1].totalReplies).to.equal(0) + expect(body.data[2].text).to.equal('super thread 3') + expect(body.data[2].totalReplies).to.equal(0) + }) + + it('Should count replies from the video author correctly', async function () { + await command.createThread({ videoId: videoUUID, text: 'my super first comment' }) + + const { data } = await command.listThreads({ videoId: videoUUID }) + const threadId2 = data[0].threadId + + const text2 = 'a first answer to thread 4 by a third party' + await command.addReply({ token: userAccessTokenServer1, videoId, toCommentId: threadId2, text: text2 }) + + const text3 = 'my second answer to thread 4' + await command.addReply({ videoId, toCommentId: threadId2, text: text3 }) + + const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 }) + expect(tree.comment.totalRepliesFromVideoAuthor).to.equal(1) + expect(tree.comment.totalReplies).to.equal(2) + }) + }) + + describe('All instance comments', function () { + + it('Should list instance comments as admin', async function () { + { + const { data, total } = await command.listForAdmin({ start: 0, count: 1 }) + + expect(total).to.equal(7) + expect(data).to.have.lengthOf(1) + expect(data[0].text).to.equal('my second answer to thread 4') + expect(data[0].account.name).to.equal('root') + expect(data[0].account.displayName).to.equal('root') + expect(data[0].account.avatars).to.have.lengthOf(2) + } + + { + const { data, total } = await command.listForAdmin({ start: 1, count: 2 }) + + expect(total).to.equal(7) + expect(data).to.have.lengthOf(2) + + expect(data[0].account.avatars).to.have.lengthOf(2) + expect(data[1].account.avatars).to.have.lengthOf(2) + } + }) + + it('Should filter instance comments by isLocal', async function () { + const { total, data } = await command.listForAdmin({ isLocal: false }) + + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + }) + + it('Should filter instance comments by onLocalVideo', async function () { + { + const { total, data } = await command.listForAdmin({ onLocalVideo: false }) + + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + } + + { + const { total, data } = await command.listForAdmin({ onLocalVideo: true }) + + expect(data).to.not.have.lengthOf(0) + expect(total).to.not.equal(0) + } + }) + + it('Should search instance comments by account', async function () { + const { total, data } = await command.listForAdmin({ searchAccount: 'user' }) + + expect(data).to.have.lengthOf(1) + expect(total).to.equal(1) + + expect(data[0].text).to.equal('a first answer to thread 4 by a third party') + }) + + it('Should search instance comments by video', async function () { + { + const { total, data } = await command.listForAdmin({ searchVideo: 'video' }) + + expect(data).to.have.lengthOf(7) + expect(total).to.equal(7) + } + + { + const { total, data } = await command.listForAdmin({ searchVideo: 'hello' }) + + expect(data).to.have.lengthOf(0) + expect(total).to.equal(0) + } + }) + + it('Should search instance comments', async function () { + const { total, data } = await command.listForAdmin({ search: 'super thread 3' }) + + expect(total).to.equal(1) + + expect(data).to.have.lengthOf(1) + expect(data[0].text).to.equal('super thread 3') + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/video-description.ts b/packages/tests/src/api/videos/video-description.ts new file mode 100644 index 000000000..eb41cd71c --- /dev/null +++ b/packages/tests/src/api/videos/video-description.ts @@ -0,0 +1,103 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test video description', function () { + let servers: PeerTubeServer[] = [] + let videoUUID = '' + let videoId: number + + const longDescription = 'my super description for server 1'.repeat(50) + + // 30 characters * 6 -> 240 characters + const truncatedDescription = 'my super description for server 1'.repeat(7) + 'my super descrip...' + + before(async function () { + this.timeout(40000) + + // Run servers + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + it('Should upload video with long description', async function () { + this.timeout(30000) + + const attributes = { + description: longDescription + } + await servers[0].videos.upload({ attributes }) + + await waitJobs(servers) + + const { data } = await servers[0].videos.list() + + videoId = data[0].id + videoUUID = data[0].uuid + }) + + it('Should have a truncated description on each server when listing videos', async function () { + for (const server of servers) { + const { data } = await server.videos.list() + const video = data.find(v => v.uuid === videoUUID) + + expect(video.description).to.equal(truncatedDescription) + expect(video.truncatedDescription).to.equal(truncatedDescription) + } + }) + + it('Should not have a truncated description on each server when getting videos', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.description).to.equal(longDescription) + expect(video.truncatedDescription).to.equal(truncatedDescription) + } + }) + + it('Should fetch long description on each server', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + const { description } = await server.videos.getDescription({ descriptionPath: video.descriptionPath }) + expect(description).to.equal(longDescription) + } + }) + + it('Should update with a short description', async function () { + const attributes = { + description: 'short description' + } + await servers[0].videos.update({ id: videoId, attributes }) + + await waitJobs(servers) + }) + + it('Should have a small description on each server', async function () { + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.description).to.equal('short description') + + const { description } = await server.videos.getDescription({ descriptionPath: video.descriptionPath }) + expect(description).to.equal('short description') + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-files.ts b/packages/tests/src/api/videos/video-files.ts new file mode 100644 index 000000000..1d7c218a4 --- /dev/null +++ b/packages/tests/src/api/videos/video-files.ts @@ -0,0 +1,202 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeRawRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test videos files', function () { + let servers: PeerTubeServer[] + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(150_000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) + }) + + describe('When deleting all files', function () { + let validId1: string + let validId2: string + + before(async function () { + this.timeout(360_000) + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'video 1' }) + validId1 = uuid + } + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'video 2' }) + validId2 = uuid + } + + await waitJobs(servers) + }) + + it('Should delete web video files', async function () { + this.timeout(30_000) + + await servers[0].videos.removeAllWebVideoFiles({ videoId: validId1 }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: validId1 }) + + expect(video.files).to.have.lengthOf(0) + expect(video.streamingPlaylists).to.have.lengthOf(1) + } + }) + + it('Should delete HLS files', async function () { + this.timeout(30_000) + + await servers[0].videos.removeHLSPlaylist({ videoId: validId2 }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: validId2 }) + + expect(video.files).to.have.length.above(0) + expect(video.streamingPlaylists).to.have.lengthOf(0) + } + }) + }) + + describe('When deleting a specific file', function () { + let webVideoId: string + let hlsId: string + + before(async function () { + this.timeout(120_000) + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'web-video' }) + webVideoId = uuid + } + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'hls' }) + hlsId = uuid + } + + await waitJobs(servers) + }) + + it('Shoulde delete a web video file', async function () { + this.timeout(30_000) + + const video = await servers[0].videos.get({ id: webVideoId }) + const files = video.files + + await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: files[0].id }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: webVideoId }) + + expect(video.files).to.have.lengthOf(files.length - 1) + expect(video.files.find(f => f.id === files[0].id)).to.not.exist + } + }) + + it('Should delete all web video files', async function () { + this.timeout(30_000) + + const video = await servers[0].videos.get({ id: webVideoId }) + const files = video.files + + for (const file of files) { + await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: file.id }) + } + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: webVideoId }) + + expect(video.files).to.have.lengthOf(0) + } + }) + + it('Should delete a hls file', async function () { + this.timeout(30_000) + + const video = await servers[0].videos.get({ id: hlsId }) + const files = video.streamingPlaylists[0].files + const toDelete = files[0] + + await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: toDelete.id }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: hlsId }) + + expect(video.streamingPlaylists[0].files).to.have.lengthOf(files.length - 1) + expect(video.streamingPlaylists[0].files.find(f => f.id === toDelete.id)).to.not.exist + + const { text } = await makeRawRequest({ url: video.streamingPlaylists[0].playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) + + expect(text.includes(`-${toDelete.resolution.id}.m3u8`)).to.be.false + expect(text.includes(`-${video.streamingPlaylists[0].files[0].resolution.id}.m3u8`)).to.be.true + } + }) + + it('Should delete all hls files', async function () { + this.timeout(30_000) + + const video = await servers[0].videos.get({ id: hlsId }) + const files = video.streamingPlaylists[0].files + + for (const file of files) { + await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: file.id }) + } + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: hlsId }) + + expect(video.streamingPlaylists).to.have.lengthOf(0) + } + }) + + it('Should not delete last file of a video', async function () { + this.timeout(60_000) + + const webVideoOnly = await servers[0].videos.get({ id: hlsId }) + const hlsOnly = await servers[0].videos.get({ id: webVideoId }) + + for (let i = 0; i < 4; i++) { + await servers[0].videos.removeWebVideoFile({ videoId: webVideoOnly.id, fileId: webVideoOnly.files[i].id }) + await servers[0].videos.removeHLSFile({ videoId: hlsOnly.id, fileId: hlsOnly.streamingPlaylists[0].files[i].id }) + } + + const expectedStatus = HttpStatusCode.BAD_REQUEST_400 + await servers[0].videos.removeWebVideoFile({ videoId: webVideoOnly.id, fileId: webVideoOnly.files[4].id, expectedStatus }) + await servers[0].videos.removeHLSFile({ videoId: hlsOnly.id, fileId: hlsOnly.streamingPlaylists[0].files[4].id, expectedStatus }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-imports.ts b/packages/tests/src/api/videos/video-imports.ts new file mode 100644 index 000000000..09efe9931 --- /dev/null +++ b/packages/tests/src/api/videos/video-imports.ts @@ -0,0 +1,634 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pathExists, remove } from 'fs-extra/esm' +import { readdir } from 'fs/promises' +import { join } from 'path' +import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils' +import { CustomConfig, HttpStatusCode, Video, VideoImportState, VideoPrivacy, VideoResolution, VideoState } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + createSingleServer, + doubleFollow, + getServerImportConfig, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { DeepPartial } from '@peertube/peertube-typescript-utils' +import { testCaptionFile } from '@tests/shared/captions.js' +import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js' +import { FIXTURE_URLS } from '@tests/shared/tests.js' + +async function checkVideosServer1 (server: PeerTubeServer, idHttp: string, idMagnet: string, idTorrent: string) { + const videoHttp = await server.videos.get({ id: idHttp }) + + expect(videoHttp.name).to.equal('small video - youtube') + expect(videoHttp.category.label).to.equal('News & Politics') + expect(videoHttp.licence.label).to.equal('Attribution') + expect(videoHttp.language.label).to.equal('Unknown') + expect(videoHttp.nsfw).to.be.false + expect(videoHttp.description).to.equal('this is a super description') + expect(videoHttp.tags).to.deep.equal([ 'tag1', 'tag2' ]) + expect(videoHttp.files).to.have.lengthOf(1) + + const originallyPublishedAt = new Date(videoHttp.originallyPublishedAt) + expect(originallyPublishedAt.getDate()).to.equal(14) + expect(originallyPublishedAt.getMonth()).to.equal(0) + expect(originallyPublishedAt.getFullYear()).to.equal(2019) + + const videoMagnet = await server.videos.get({ id: idMagnet }) + const videoTorrent = await server.videos.get({ id: idTorrent }) + + for (const video of [ videoMagnet, videoTorrent ]) { + expect(video.category.label).to.equal('Unknown') + expect(video.licence.label).to.equal('Unknown') + expect(video.language.label).to.equal('Unknown') + expect(video.nsfw).to.be.false + expect(video.description).to.equal('this is a super torrent description') + expect(video.tags).to.deep.equal([ 'tag_torrent1', 'tag_torrent2' ]) + expect(video.files).to.have.lengthOf(1) + } + + expect(videoTorrent.name).to.contain('你好 世界 720p.mp4') + expect(videoMagnet.name).to.contain('super peertube2 video') + + const bodyCaptions = await server.captions.list({ videoId: idHttp }) + expect(bodyCaptions.total).to.equal(2) +} + +async function checkVideoServer2 (server: PeerTubeServer, id: number | string) { + const video = await server.videos.get({ id }) + + expect(video.name).to.equal('my super name') + expect(video.category.label).to.equal('Entertainment') + expect(video.licence.label).to.equal('Public Domain Dedication') + expect(video.language.label).to.equal('English') + expect(video.nsfw).to.be.false + expect(video.description).to.equal('my super description') + expect(video.tags).to.deep.equal([ 'supertag1', 'supertag2' ]) + + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', video.thumbnailPath) + + expect(video.files).to.have.lengthOf(1) + + const bodyCaptions = await server.captions.list({ videoId: id }) + expect(bodyCaptions.total).to.equal(2) +} + +describe('Test video imports', function () { + + if (areHttpImportTestsDisabled()) return + + function runSuite (mode: 'youtube-dl' | 'yt-dlp') { + + describe('Import ' + mode, function () { + let servers: PeerTubeServer[] = [] + + before(async function () { + this.timeout(60_000) + + servers = await createMultipleServers(2, getServerImportConfig(mode)) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + for (const server of servers) { + await server.config.updateExistingSubConfig({ + newConfig: { + transcoding: { + alwaysTranscodeOriginalResolution: false + } + } + }) + } + + await doubleFollow(servers[0], servers[1]) + }) + + it('Should import videos on server 1', async function () { + this.timeout(60_000) + + const baseAttributes = { + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + + { + const attributes = { ...baseAttributes, targetUrl: FIXTURE_URLS.youtube } + const { video } = await servers[0].imports.importVideo({ attributes }) + expect(video.name).to.equal('small video - youtube') + + { + expect(video.thumbnailPath).to.match(new RegExp(`^/lazy-static/thumbnails/.+.jpg$`)) + expect(video.previewPath).to.match(new RegExp(`^/lazy-static/previews/.+.jpg$`)) + + const suffix = mode === 'yt-dlp' + ? '_yt_dlp' + : '' + + await testImageGeneratedByFFmpeg(servers[0].url, 'video_import_thumbnail' + suffix, video.thumbnailPath) + await testImageGeneratedByFFmpeg(servers[0].url, 'video_import_preview' + suffix, video.previewPath) + } + + const bodyCaptions = await servers[0].captions.list({ videoId: video.id }) + const videoCaptions = bodyCaptions.data + expect(videoCaptions).to.have.lengthOf(2) + + { + const enCaption = videoCaptions.find(caption => caption.language.id === 'en') + expect(enCaption).to.exist + expect(enCaption.language.label).to.equal('English') + expect(enCaption.captionPath).to.match(new RegExp(`^/lazy-static/video-captions/.+-en.vtt$`)) + + const regex = `WEBVTT[ \n]+Kind: captions[ \n]+` + + `(Language: en[ \n]+)?` + + `00:00:01.600 --> 00:00:04.200( position:\\d+% line:\\d+%)?[ \n]+English \\(US\\)[ \n]+` + + `00:00:05.900 --> 00:00:07.999( position:\\d+% line:\\d+%)?[ \n]+This is a subtitle in American English[ \n]+` + + `00:00:10.000 --> 00:00:14.000( position:\\d+% line:\\d+%)?[ \n]+Adding subtitles is very easy to do` + await testCaptionFile(servers[0].url, enCaption.captionPath, new RegExp(regex)) + } + + { + const frCaption = videoCaptions.find(caption => caption.language.id === 'fr') + expect(frCaption).to.exist + expect(frCaption.language.label).to.equal('French') + expect(frCaption.captionPath).to.match(new RegExp(`^/lazy-static/video-captions/.+-fr.vtt`)) + + const regex = `WEBVTT[ \n]+Kind: captions[ \n]+` + + `(Language: fr[ \n]+)?` + + `00:00:01.600 --> 00:00:04.200( position:\\d+% line:\\d+%)?[ \n]+Français \\(FR\\)[ \n]+` + + `00:00:05.900 --> 00:00:07.999( position:\\d+% line:\\d+%)?[ \n]+C'est un sous-titre français[ \n]+` + + `00:00:10.000 --> 00:00:14.000( position:\\d+% line:\\d+%)?[ \n]+Ajouter un sous-titre est vraiment facile` + + await testCaptionFile(servers[0].url, frCaption.captionPath, new RegExp(regex)) + } + } + + { + const attributes = { + ...baseAttributes, + magnetUri: FIXTURE_URLS.magnet, + description: 'this is a super torrent description', + tags: [ 'tag_torrent1', 'tag_torrent2' ] + } + const { video } = await servers[0].imports.importVideo({ attributes }) + expect(video.name).to.equal('super peertube2 video') + } + + { + const attributes = { + ...baseAttributes, + torrentfile: 'video-720p.torrent' as any, + description: 'this is a super torrent description', + tags: [ 'tag_torrent1', 'tag_torrent2' ] + } + const { video } = await servers[0].imports.importVideo({ attributes }) + expect(video.name).to.equal('你好 世界 720p.mp4') + } + }) + + it('Should list the videos to import in my videos on server 1', async function () { + const { total, data } = await servers[0].videos.listMyVideos({ sort: 'createdAt' }) + + expect(total).to.equal(3) + + expect(data).to.have.lengthOf(3) + expect(data[0].name).to.equal('small video - youtube') + expect(data[1].name).to.equal('super peertube2 video') + expect(data[2].name).to.equal('你好 世界 720p.mp4') + }) + + it('Should list the videos to import in my imports on server 1', async function () { + const { total, data: videoImports } = await servers[0].imports.getMyVideoImports({ sort: '-createdAt' }) + expect(total).to.equal(3) + + expect(videoImports).to.have.lengthOf(3) + + expect(videoImports[2].targetUrl).to.equal(FIXTURE_URLS.youtube) + expect(videoImports[2].magnetUri).to.be.null + expect(videoImports[2].torrentName).to.be.null + expect(videoImports[2].video.name).to.equal('small video - youtube') + + expect(videoImports[1].targetUrl).to.be.null + expect(videoImports[1].magnetUri).to.equal(FIXTURE_URLS.magnet) + expect(videoImports[1].torrentName).to.be.null + expect(videoImports[1].video.name).to.equal('super peertube2 video') + + expect(videoImports[0].targetUrl).to.be.null + expect(videoImports[0].magnetUri).to.be.null + expect(videoImports[0].torrentName).to.equal('video-720p.torrent') + expect(videoImports[0].video.name).to.equal('你好 世界 720p.mp4') + }) + + it('Should filter my imports on target URL', async function () { + const { total, data: videoImports } = await servers[0].imports.getMyVideoImports({ targetUrl: FIXTURE_URLS.youtube }) + expect(total).to.equal(1) + expect(videoImports).to.have.lengthOf(1) + + expect(videoImports[0].targetUrl).to.equal(FIXTURE_URLS.youtube) + }) + + it('Should search in my imports', async function () { + const { total, data: videoImports } = await servers[0].imports.getMyVideoImports({ search: 'peertube2' }) + expect(total).to.equal(1) + expect(videoImports).to.have.lengthOf(1) + + expect(videoImports[0].magnetUri).to.equal(FIXTURE_URLS.magnet) + expect(videoImports[0].video.name).to.equal('super peertube2 video') + }) + + it('Should have the video listed on the two instances', async function () { + this.timeout(120_000) + + await waitJobs(servers) + + for (const server of servers) { + const { total, data } = await server.videos.list() + expect(total).to.equal(3) + expect(data).to.have.lengthOf(3) + + const [ videoHttp, videoMagnet, videoTorrent ] = data + await checkVideosServer1(server, videoHttp.uuid, videoMagnet.uuid, videoTorrent.uuid) + } + }) + + it('Should import a video on server 2 with some fields', async function () { + this.timeout(60_000) + + const { video } = await servers[1].imports.importVideo({ + attributes: { + targetUrl: FIXTURE_URLS.youtube, + channelId: servers[1].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + category: 10, + licence: 7, + language: 'en', + name: 'my super name', + description: 'my super description', + tags: [ 'supertag1', 'supertag2' ], + thumbnailfile: 'custom-thumbnail.jpg' + } + }) + expect(video.name).to.equal('my super name') + }) + + it('Should have the videos listed on the two instances', async function () { + this.timeout(120_000) + + await waitJobs(servers) + + for (const server of servers) { + const { total, data } = await server.videos.list() + expect(total).to.equal(4) + expect(data).to.have.lengthOf(4) + + await checkVideoServer2(server, data[0].uuid) + + const [ , videoHttp, videoMagnet, videoTorrent ] = data + await checkVideosServer1(server, videoHttp.uuid, videoMagnet.uuid, videoTorrent.uuid) + } + }) + + it('Should import a video that will be transcoded', async function () { + this.timeout(240_000) + + const attributes = { + name: 'transcoded video', + magnetUri: FIXTURE_URLS.magnet, + channelId: servers[1].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + const { video } = await servers[1].imports.importVideo({ attributes }) + const videoUUID = video.uuid + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.name).to.equal('transcoded video') + expect(video.files).to.have.lengthOf(4) + } + }) + + it('Should import no HDR version on a HDR video', async function () { + this.timeout(300_000) + + const config: DeepPartial = { + transcoding: { + enabled: true, + resolutions: { + '0p': false, + '144p': true, + '240p': true, + '360p': false, + '480p': false, + '720p': false, + '1080p': false, // the resulting resolution shouldn't be higher than this, and not vp9.2/av01 + '1440p': false, + '2160p': false + }, + webVideos: { enabled: true }, + hls: { enabled: false } + } + } + await servers[0].config.updateExistingSubConfig({ newConfig: config }) + + const attributes = { + name: 'hdr video', + targetUrl: FIXTURE_URLS.youtubeHDR, + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + const { video: videoImported } = await servers[0].imports.importVideo({ attributes }) + const videoUUID = videoImported.uuid + + await waitJobs(servers) + + // test resolution + const video = await servers[0].videos.get({ id: videoUUID }) + expect(video.name).to.equal('hdr video') + const maxResolution = Math.max.apply(Math, video.files.map(function (o) { return o.resolution.id })) + expect(maxResolution, 'expected max resolution not met').to.equals(VideoResolution.H_240P) + }) + + it('Should not import resolution higher than enabled transcoding resolution', async function () { + this.timeout(300_000) + + const config: DeepPartial = { + transcoding: { + enabled: true, + resolutions: { + '0p': false, + '144p': true, + '240p': false, + '360p': false, + '480p': false, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + }, + alwaysTranscodeOriginalResolution: false + } + } + await servers[0].config.updateExistingSubConfig({ newConfig: config }) + + const attributes = { + name: 'small resolution video', + targetUrl: FIXTURE_URLS.youtube, + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + const { video: videoImported } = await servers[0].imports.importVideo({ attributes }) + const videoUUID = videoImported.uuid + + await waitJobs(servers) + + // test resolution + const video = await servers[0].videos.get({ id: videoUUID }) + expect(video.name).to.equal('small resolution video') + expect(video.files).to.have.lengthOf(1) + expect(video.files[0].resolution.id).to.equal(144) + }) + + it('Should import resolution higher than enabled transcoding resolution', async function () { + this.timeout(300_000) + + const config: DeepPartial = { + transcoding: { + alwaysTranscodeOriginalResolution: true + } + } + await servers[0].config.updateExistingSubConfig({ newConfig: config }) + + const attributes = { + name: 'bigger resolution video', + targetUrl: FIXTURE_URLS.youtube, + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + const { video: videoImported } = await servers[0].imports.importVideo({ attributes }) + const videoUUID = videoImported.uuid + + await waitJobs(servers) + + // test resolution + const video = await servers[0].videos.get({ id: videoUUID }) + expect(video.name).to.equal('bigger resolution video') + + expect(video.files).to.have.lengthOf(2) + expect(video.files.find(f => f.resolution.id === 240)).to.exist + expect(video.files.find(f => f.resolution.id === 144)).to.exist + }) + + it('Should import a peertube video', async function () { + this.timeout(120_000) + + const toTest = [ FIXTURE_URLS.peertube_long ] + + // TODO: include peertube_short when https://github.com/ytdl-org/youtube-dl/pull/29475 is merged + if (mode === 'yt-dlp') { + toTest.push(FIXTURE_URLS.peertube_short) + } + + for (const targetUrl of toTest) { + await servers[0].config.disableTranscoding() + + const attributes = { + targetUrl, + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + const { video } = await servers[0].imports.importVideo({ attributes }) + const videoUUID = video.uuid + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: videoUUID }) + + expect(video.name).to.equal('E2E tests') + + const { data: captions } = await server.captions.list({ videoId: videoUUID }) + expect(captions).to.have.lengthOf(1) + expect(captions[0].language.id).to.equal('fr') + + const str = `WEBVTT FILE\r?\n\r?\n` + + `1\r?\n` + + `00:00:04.000 --> 00:00:09.000\r?\n` + + `January 1, 1994. The North American` + await testCaptionFile(server.url, captions[0].captionPath, new RegExp(str)) + } + } + }) + + after(async function () { + await cleanupTests(servers) + }) + }) + } + + // FIXME: youtube-dl seems broken + // runSuite('youtube-dl') + + runSuite('yt-dlp') + + describe('Delete/cancel an import', function () { + let server: PeerTubeServer + + let finishedImportId: number + let finishedVideo: Video + let pendingImportId: number + + async function importVideo (name: string) { + const attributes = { name, channelId: server.store.channel.id, targetUrl: FIXTURE_URLS.goodVideo } + const res = await server.imports.importVideo({ attributes }) + + return res.id + } + + before(async function () { + this.timeout(120_000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + finishedImportId = await importVideo('finished') + await waitJobs([ server ]) + + await server.jobs.pauseJobQueue() + pendingImportId = await importVideo('pending') + + const { data } = await server.imports.getMyVideoImports() + expect(data).to.have.lengthOf(2) + + finishedVideo = data.find(i => i.id === finishedImportId).video + }) + + it('Should delete a video import', async function () { + await server.imports.delete({ importId: finishedImportId }) + + const { data } = await server.imports.getMyVideoImports() + expect(data).to.have.lengthOf(1) + expect(data[0].id).to.equal(pendingImportId) + expect(data[0].state.id).to.equal(VideoImportState.PENDING) + }) + + it('Should not have deleted the associated video', async function () { + const video = await server.videos.get({ id: finishedVideo.id, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + expect(video.name).to.equal('finished') + expect(video.state.id).to.equal(VideoState.PUBLISHED) + }) + + it('Should cancel a video import', async function () { + await server.imports.cancel({ importId: pendingImportId }) + + const { data } = await server.imports.getMyVideoImports() + expect(data).to.have.lengthOf(1) + expect(data[0].id).to.equal(pendingImportId) + expect(data[0].state.id).to.equal(VideoImportState.CANCELLED) + }) + + it('Should not have processed the cancelled video import', async function () { + this.timeout(60_000) + + await server.jobs.resumeJobQueue() + + await waitJobs([ server ]) + + const { data } = await server.imports.getMyVideoImports() + expect(data).to.have.lengthOf(1) + expect(data[0].id).to.equal(pendingImportId) + expect(data[0].state.id).to.equal(VideoImportState.CANCELLED) + expect(data[0].video.state.id).to.equal(VideoState.TO_IMPORT) + }) + + it('Should delete the cancelled video import', async function () { + await server.imports.delete({ importId: pendingImportId }) + const { data } = await server.imports.getMyVideoImports() + expect(data).to.have.lengthOf(0) + }) + + after(async function () { + await cleanupTests([ server ]) + }) + }) + + describe('Auto update', function () { + let server: PeerTubeServer + + function quickPeerTubeImport () { + const attributes = { + targetUrl: FIXTURE_URLS.peertube_long, + channelId: server.store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + + return server.imports.importVideo({ attributes }) + } + + async function testBinaryUpdate (releaseUrl: string, releaseName: string) { + await remove(join(server.servers.buildDirectory('bin'), releaseName)) + + await server.kill() + await server.run({ + import: { + videos: { + http: { + youtube_dl_release: { + url: releaseUrl, + name: releaseName + } + } + } + } + }) + + await quickPeerTubeImport() + + const base = server.servers.buildDirectory('bin') + const content = await readdir(base) + const binaryPath = join(base, releaseName) + + expect(await pathExists(binaryPath), `${binaryPath} does not exist in ${base} (${content.join(', ')})`).to.be.true + } + + before(async function () { + this.timeout(30_000) + + // Run servers + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + }) + + it('Should update youtube-dl from github URL', async function () { + this.timeout(120_000) + + await testBinaryUpdate('https://api.github.com/repos/ytdl-org/youtube-dl/releases', 'youtube-dl') + }) + + it('Should update youtube-dl from raw URL', async function () { + this.timeout(120_000) + + await testBinaryUpdate('https://yt-dl.org/downloads/latest/youtube-dl', 'youtube-dl') + }) + + it('Should update youtube-dl from youtube-dl fork', async function () { + this.timeout(120_000) + + await testBinaryUpdate('https://api.github.com/repos/yt-dlp/yt-dlp/releases', 'yt-dlp') + }) + + after(async function () { + await cleanupTests([ server ]) + }) + }) +}) diff --git a/packages/tests/src/api/videos/video-nsfw.ts b/packages/tests/src/api/videos/video-nsfw.ts new file mode 100644 index 000000000..fc5225dd2 --- /dev/null +++ b/packages/tests/src/api/videos/video-nsfw.ts @@ -0,0 +1,227 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' +import { BooleanBothQuery, CustomConfig, ResultList, Video, VideosOverview } from '@peertube/peertube-models' + +function createOverviewRes (overview: VideosOverview) { + const videos = overview.categories[0].videos + return { data: videos, total: videos.length } +} + +describe('Test video NSFW policy', function () { + let server: PeerTubeServer + let userAccessToken: string + let customConfig: CustomConfig + + async function getVideosFunctions (token?: string, query: { nsfw?: BooleanBothQuery } = {}) { + const user = await server.users.getMyInfo() + + const channelName = user.videoChannels[0].name + const accountName = user.account.name + '@' + user.account.host + + const hasQuery = Object.keys(query).length !== 0 + let promises: Promise>[] + + if (token) { + promises = [ + server.search.advancedVideoSearch({ token, search: { search: 'n', sort: '-publishedAt', ...query } }), + server.videos.listWithToken({ token, ...query }), + server.videos.listByAccount({ token, handle: accountName, ...query }), + server.videos.listByChannel({ token, handle: channelName, ...query }) + ] + + // Overviews do not support video filters + if (!hasQuery) { + const p = server.overviews.getVideos({ page: 1, token }) + .then(res => createOverviewRes(res)) + promises.push(p) + } + + return Promise.all(promises) + } + + promises = [ + server.search.searchVideos({ search: 'n', sort: '-publishedAt' }), + server.videos.list(), + server.videos.listByAccount({ token: null, handle: accountName }), + server.videos.listByChannel({ token: null, handle: channelName }) + ] + + // Overviews do not support video filters + if (!hasQuery) { + const p = server.overviews.getVideos({ page: 1 }) + .then(res => createOverviewRes(res)) + promises.push(p) + } + + return Promise.all(promises) + } + + before(async function () { + this.timeout(50000) + server = await createSingleServer(1) + + // Get the access tokens + await setAccessTokensToServers([ server ]) + + { + const attributes = { name: 'nsfw', nsfw: true, category: 1 } + await server.videos.upload({ attributes }) + } + + { + const attributes = { name: 'normal', nsfw: false, category: 1 } + await server.videos.upload({ attributes }) + } + + customConfig = await server.config.getCustomConfig() + }) + + describe('Instance default NSFW policy', function () { + + it('Should display NSFW videos with display default NSFW policy', async function () { + const serverConfig = await server.config.getConfig() + expect(serverConfig.instance.defaultNSFWPolicy).to.equal('display') + + for (const body of await getVideosFunctions()) { + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + expect(videos[0].name).to.equal('normal') + expect(videos[1].name).to.equal('nsfw') + } + }) + + it('Should not display NSFW videos with do_not_list default NSFW policy', async function () { + customConfig.instance.defaultNSFWPolicy = 'do_not_list' + await server.config.updateCustomConfig({ newCustomConfig: customConfig }) + + const serverConfig = await server.config.getConfig() + expect(serverConfig.instance.defaultNSFWPolicy).to.equal('do_not_list') + + for (const body of await getVideosFunctions()) { + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos).to.have.lengthOf(1) + expect(videos[0].name).to.equal('normal') + } + }) + + it('Should display NSFW videos with blur default NSFW policy', async function () { + customConfig.instance.defaultNSFWPolicy = 'blur' + await server.config.updateCustomConfig({ newCustomConfig: customConfig }) + + const serverConfig = await server.config.getConfig() + expect(serverConfig.instance.defaultNSFWPolicy).to.equal('blur') + + for (const body of await getVideosFunctions()) { + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + expect(videos[0].name).to.equal('normal') + expect(videos[1].name).to.equal('nsfw') + } + }) + }) + + describe('User NSFW policy', function () { + + it('Should create a user having the default nsfw policy', async function () { + const username = 'user1' + const password = 'my super password' + await server.users.create({ username, password }) + + userAccessToken = await server.login.getAccessToken({ username, password }) + + const user = await server.users.getMyInfo({ token: userAccessToken }) + expect(user.nsfwPolicy).to.equal('blur') + }) + + it('Should display NSFW videos with blur user NSFW policy', async function () { + customConfig.instance.defaultNSFWPolicy = 'do_not_list' + await server.config.updateCustomConfig({ newCustomConfig: customConfig }) + + for (const body of await getVideosFunctions(userAccessToken)) { + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + expect(videos[0].name).to.equal('normal') + expect(videos[1].name).to.equal('nsfw') + } + }) + + it('Should display NSFW videos with display user NSFW policy', async function () { + await server.users.updateMe({ nsfwPolicy: 'display' }) + + for (const body of await getVideosFunctions(server.accessToken)) { + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + expect(videos[0].name).to.equal('normal') + expect(videos[1].name).to.equal('nsfw') + } + }) + + it('Should not display NSFW videos with do_not_list user NSFW policy', async function () { + await server.users.updateMe({ nsfwPolicy: 'do_not_list' }) + + for (const body of await getVideosFunctions(server.accessToken)) { + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos).to.have.lengthOf(1) + expect(videos[0].name).to.equal('normal') + } + }) + + it('Should be able to see my NSFW videos even with do_not_list user NSFW policy', async function () { + const { total, data } = await server.videos.listMyVideos() + expect(total).to.equal(2) + + expect(data).to.have.lengthOf(2) + expect(data[0].name).to.equal('normal') + expect(data[1].name).to.equal('nsfw') + }) + + it('Should display NSFW videos when the nsfw param === true', async function () { + for (const body of await getVideosFunctions(server.accessToken, { nsfw: 'true' })) { + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos).to.have.lengthOf(1) + expect(videos[0].name).to.equal('nsfw') + } + }) + + it('Should hide NSFW videos when the nsfw param === true', async function () { + for (const body of await getVideosFunctions(server.accessToken, { nsfw: 'false' })) { + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos).to.have.lengthOf(1) + expect(videos[0].name).to.equal('normal') + } + }) + + it('Should display both videos when the nsfw param === both', async function () { + for (const body of await getVideosFunctions(server.accessToken, { nsfw: 'both' })) { + expect(body.total).to.equal(2) + + const videos = body.data + expect(videos).to.have.lengthOf(2) + expect(videos[0].name).to.equal('normal') + expect(videos[1].name).to.equal('nsfw') + } + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/video-passwords.ts b/packages/tests/src/api/videos/video-passwords.ts new file mode 100644 index 000000000..60e0e28bd --- /dev/null +++ b/packages/tests/src/api/videos/video-passwords.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { + cleanupTests, + createSingleServer, + VideoPasswordsCommand, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar +} from '@peertube/peertube-server-commands' +import { VideoPrivacy } from '@peertube/peertube-models' + +describe('Test video passwords', function () { + let server: PeerTubeServer + let videoUUID: string + + let userAccessTokenServer1: string + + let videoPasswords: string[] = [] + let command: VideoPasswordsCommand + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + for (let i = 0; i < 10; i++) { + videoPasswords.push(`password ${i + 1}`) + } + const { uuid } = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords } }) + videoUUID = uuid + + await setDefaultChannelAvatar(server) + await setDefaultAccountAvatar(server) + + userAccessTokenServer1 = await server.users.generateUserAndToken('user1') + await setDefaultChannelAvatar(server, 'user1_channel') + await setDefaultAccountAvatar(server, userAccessTokenServer1) + + command = server.videoPasswords + }) + + it('Should list video passwords', async function () { + const body = await command.list({ videoId: videoUUID }) + + expect(body.total).to.equal(10) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(10) + }) + + it('Should filter passwords on this video', async function () { + const body = await command.list({ videoId: videoUUID, count: 2, start: 3, sort: 'createdAt' }) + + expect(body.total).to.equal(10) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + expect(body.data[0].password).to.equal('password 4') + expect(body.data[1].password).to.equal('password 5') + }) + + it('Should update password for this video', async function () { + videoPasswords = [ 'my super new password 1', 'my super new password 2' ] + + await command.updateAll({ videoId: videoUUID, passwords: videoPasswords }) + const body = await command.list({ videoId: videoUUID }) + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + expect(body.data[0].password).to.equal('my super new password 2') + expect(body.data[1].password).to.equal('my super new password 1') + }) + + it('Should delete one password', async function () { + { + const body = await command.list({ videoId: videoUUID }) + expect(body.total).to.equal(2) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(2) + await command.remove({ id: body.data[0].id, videoId: videoUUID }) + } + { + const body = await command.list({ videoId: videoUUID }) + + expect(body.total).to.equal(1) + expect(body.data).to.be.an('array') + expect(body.data).to.have.lengthOf(1) + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/video-playlist-thumbnails.ts b/packages/tests/src/api/videos/video-playlist-thumbnails.ts new file mode 100644 index 000000000..d79c92f72 --- /dev/null +++ b/packages/tests/src/api/videos/video-playlist-thumbnails.ts @@ -0,0 +1,234 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js' +import { VideoPlaylistPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Playlist thumbnail', function () { + let servers: PeerTubeServer[] = [] + + let playlistWithoutThumbnailId: number + let playlistWithThumbnailId: number + + let withThumbnailE1: number + let withThumbnailE2: number + let withoutThumbnailE1: number + let withoutThumbnailE2: number + + let video1: number + let video2: number + + async function getPlaylistWithoutThumbnail (server: PeerTubeServer) { + const body = await server.playlists.list({ start: 0, count: 10 }) + + return body.data.find(p => p.displayName === 'playlist without thumbnail') + } + + async function getPlaylistWithThumbnail (server: PeerTubeServer) { + const body = await server.playlists.list({ start: 0, count: 10 }) + + return body.data.find(p => p.displayName === 'playlist with thumbnail') + } + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + for (const server of servers) { + await server.config.disableTranscoding() + } + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + + video1 = (await servers[0].videos.quickUpload({ name: 'video 1' })).id + video2 = (await servers[0].videos.quickUpload({ name: 'video 2' })).id + + await waitJobs(servers) + }) + + it('Should automatically update the thumbnail when adding an element', async function () { + this.timeout(30000) + + const created = await servers[1].playlists.create({ + attributes: { + displayName: 'playlist without thumbnail', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: servers[1].store.channel.id + } + }) + playlistWithoutThumbnailId = created.id + + const added = await servers[1].playlists.addElement({ + playlistId: playlistWithoutThumbnailId, + attributes: { videoId: video1 } + }) + withoutThumbnailE1 = added.id + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithoutThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailPath) + } + }) + + it('Should not update the thumbnail if we explicitly uploaded a thumbnail', async function () { + this.timeout(30000) + + const created = await servers[1].playlists.create({ + attributes: { + displayName: 'playlist with thumbnail', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: servers[1].store.channel.id, + thumbnailfile: 'custom-thumbnail.jpg' + } + }) + playlistWithThumbnailId = created.id + + const added = await servers[1].playlists.addElement({ + playlistId: playlistWithThumbnailId, + attributes: { videoId: video1 } + }) + withThumbnailE1 = added.id + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath) + } + }) + + it('Should automatically update the thumbnail when moving the first element', async function () { + this.timeout(30000) + + const added = await servers[1].playlists.addElement({ + playlistId: playlistWithoutThumbnailId, + attributes: { videoId: video2 } + }) + withoutThumbnailE2 = added.id + + await servers[1].playlists.reorderElements({ + playlistId: playlistWithoutThumbnailId, + attributes: { + startPosition: 1, + insertAfterPosition: 2 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithoutThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailPath) + } + }) + + it('Should not update the thumbnail when moving the first element if we explicitly uploaded a thumbnail', async function () { + this.timeout(30000) + + const added = await servers[1].playlists.addElement({ + playlistId: playlistWithThumbnailId, + attributes: { videoId: video2 } + }) + withThumbnailE2 = added.id + + await servers[1].playlists.reorderElements({ + playlistId: playlistWithThumbnailId, + attributes: { + startPosition: 1, + insertAfterPosition: 2 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath) + } + }) + + it('Should automatically update the thumbnail when deleting the first element', async function () { + this.timeout(30000) + + await servers[1].playlists.removeElement({ + playlistId: playlistWithoutThumbnailId, + elementId: withoutThumbnailE1 + }) + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithoutThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailPath) + } + }) + + it('Should not update the thumbnail when deleting the first element if we explicitly uploaded a thumbnail', async function () { + this.timeout(30000) + + await servers[1].playlists.removeElement({ + playlistId: playlistWithThumbnailId, + elementId: withThumbnailE1 + }) + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath) + } + }) + + it('Should the thumbnail when we delete the last element', async function () { + this.timeout(30000) + + await servers[1].playlists.removeElement({ + playlistId: playlistWithoutThumbnailId, + elementId: withoutThumbnailE2 + }) + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithoutThumbnail(server) + expect(p.thumbnailPath).to.be.null + } + }) + + it('Should not update the thumbnail when we delete the last element if we explicitly uploaded a thumbnail', async function () { + this.timeout(30000) + + await servers[1].playlists.removeElement({ + playlistId: playlistWithThumbnailId, + elementId: withThumbnailE2 + }) + + await waitJobs(servers) + + for (const server of servers) { + const p = await getPlaylistWithThumbnail(server) + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath) + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-playlists.ts b/packages/tests/src/api/videos/video-playlists.ts new file mode 100644 index 000000000..578d01093 --- /dev/null +++ b/packages/tests/src/api/videos/video-playlists.ts @@ -0,0 +1,1210 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + VideoPlaylist, + VideoPlaylistCreateResult, + VideoPlaylistElementType, + VideoPlaylistElementType_Type, + VideoPlaylistPrivacy, + VideoPlaylistType, + VideoPrivacy +} from '@peertube/peertube-models' +import { uuidToShort } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + PlaylistsCommand, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js' +import { checkPlaylistFilesWereRemoved } from '@tests/shared/video-playlists.js' + +async function checkPlaylistElementType ( + servers: PeerTubeServer[], + playlistId: string, + type: VideoPlaylistElementType_Type, + position: number, + name: string, + total: number +) { + for (const server of servers) { + const body = await server.playlists.listVideos({ token: server.accessToken, playlistId, start: 0, count: 10 }) + expect(body.total).to.equal(total) + + const videoElement = body.data.find(e => e.position === position) + expect(videoElement.type).to.equal(type, 'On server ' + server.url) + + if (type === VideoPlaylistElementType.REGULAR) { + expect(videoElement.video).to.not.be.null + expect(videoElement.video.name).to.equal(name) + } else { + expect(videoElement.video).to.be.null + } + } +} + +describe('Test video playlists', function () { + let servers: PeerTubeServer[] = [] + + let playlistServer2Id1: number + let playlistServer2Id2: number + let playlistServer2UUID2: string + + let playlistServer1Id: number + let playlistServer1DisplayName: string + let playlistServer1UUID: string + let playlistServer1UUID2: string + + let playlistElementServer1Video4: number + let playlistElementServer1Video5: number + let playlistElementNSFW: number + + let nsfwVideoServer1: number + + let userTokenServer1: string + + let commands: PlaylistsCommand[] + + before(async function () { + this.timeout(240000) + + servers = await createMultipleServers(3) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultAccountAvatar(servers) + + for (const server of servers) { + await server.config.disableTranscoding() + } + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + // Server 1 and server 3 follow each other + await doubleFollow(servers[0], servers[2]) + + commands = servers.map(s => s.playlists) + + { + servers[0].store.videos = [] + servers[1].store.videos = [] + servers[2].store.videos = [] + + for (const server of servers) { + for (let i = 0; i < 7; i++) { + const name = `video ${i} server ${server.serverNumber}` + const video = await server.videos.upload({ attributes: { name, nsfw: false } }) + + server.store.videos.push(video) + } + } + } + + nsfwVideoServer1 = (await servers[0].videos.quickUpload({ name: 'NSFW video', nsfw: true })).id + + userTokenServer1 = await servers[0].users.generateUserAndToken('user1') + + await waitJobs(servers) + }) + + describe('Check playlists filters and privacies', function () { + + it('Should list video playlist privacies', async function () { + const privacies = await commands[0].getPrivacies() + + expect(Object.keys(privacies)).to.have.length.at.least(3) + expect(privacies[3]).to.equal('Private') + }) + + it('Should filter on playlist type', async function () { + this.timeout(30000) + + const token = servers[0].accessToken + + await commands[0].create({ + attributes: { + displayName: 'my super playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + description: 'my super description', + thumbnailfile: 'custom-thumbnail.jpg', + videoChannelId: servers[0].store.channel.id + } + }) + + { + const body = await commands[0].listByAccount({ token, handle: 'root', playlistType: VideoPlaylistType.WATCH_LATER }) + + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const playlist = body.data[0] + expect(playlist.displayName).to.equal('Watch later') + expect(playlist.type.id).to.equal(VideoPlaylistType.WATCH_LATER) + expect(playlist.type.label).to.equal('Watch later') + expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.PRIVATE) + } + + { + const bodyList = await commands[0].list({ playlistType: VideoPlaylistType.WATCH_LATER }) + const bodyChannel = await commands[0].listByChannel({ handle: 'root_channel', playlistType: VideoPlaylistType.WATCH_LATER }) + + for (const body of [ bodyList, bodyChannel ]) { + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + } + + { + const bodyList = await commands[0].list({ playlistType: VideoPlaylistType.REGULAR }) + const bodyChannel = await commands[0].listByChannel({ handle: 'root_channel', playlistType: VideoPlaylistType.REGULAR }) + + let playlist: VideoPlaylist = null + for (const body of [ bodyList, bodyChannel ]) { + + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + playlist = body.data[0] + expect(playlist.displayName).to.equal('my super playlist') + expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC) + expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR) + } + + await commands[0].update({ + playlistId: playlist.id, + attributes: { + privacy: VideoPlaylistPrivacy.PRIVATE + } + }) + } + + { + const bodyList = await commands[0].list({ playlistType: VideoPlaylistType.REGULAR }) + const bodyChannel = await commands[0].listByChannel({ handle: 'root_channel', playlistType: VideoPlaylistType.REGULAR }) + + for (const body of [ bodyList, bodyChannel ]) { + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + } + + { + const body = await commands[0].listByAccount({ handle: 'root' }) + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + } + }) + + it('Should get private playlist for a classic user', async function () { + const token = await servers[0].users.generateUserAndToken('toto') + + const body = await commands[0].listByAccount({ token, handle: 'toto' }) + + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const playlistId = body.data[0].id + await commands[0].listVideos({ token, playlistId }) + }) + }) + + describe('Create and federate playlists', function () { + + it('Should create a playlist on server 1 and have the playlist on server 2 and 3', async function () { + this.timeout(30000) + + await commands[0].create({ + attributes: { + displayName: 'my super playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + description: 'my super description', + thumbnailfile: 'custom-thumbnail.jpg', + videoChannelId: servers[0].store.channel.id + } + }) + + await waitJobs(servers) + // Processing a playlist by the receiver could be long + await wait(3000) + + for (const server of servers) { + const body = await server.playlists.list({ start: 0, count: 5 }) + expect(body.total).to.equal(1) + expect(body.data).to.have.lengthOf(1) + + const playlistFromList = body.data[0] + + const playlistFromGet = await server.playlists.get({ playlistId: playlistFromList.uuid }) + + for (const playlist of [ playlistFromGet, playlistFromList ]) { + expect(playlist.id).to.be.a('number') + expect(playlist.uuid).to.be.a('string') + + expect(playlist.isLocal).to.equal(server.serverNumber === 1) + + expect(playlist.displayName).to.equal('my super playlist') + expect(playlist.description).to.equal('my super description') + expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC) + expect(playlist.privacy.label).to.equal('Public') + expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR) + expect(playlist.type.label).to.equal('Regular') + expect(playlist.embedPath).to.equal('/video-playlists/embed/' + playlist.uuid) + + expect(playlist.videosLength).to.equal(0) + + expect(playlist.ownerAccount.name).to.equal('root') + expect(playlist.ownerAccount.displayName).to.equal('root') + expect(playlist.videoChannel.name).to.equal('root_channel') + expect(playlist.videoChannel.displayName).to.equal('Main root channel') + } + } + }) + + it('Should create a playlist on server 2 and have the playlist on server 1 but not on server 3', async function () { + this.timeout(30000) + + { + const playlist = await servers[1].playlists.create({ + attributes: { + displayName: 'playlist 2', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: servers[1].store.channel.id + } + }) + playlistServer2Id1 = playlist.id + } + + { + const playlist = await servers[1].playlists.create({ + attributes: { + displayName: 'playlist 3', + privacy: VideoPlaylistPrivacy.PUBLIC, + thumbnailfile: 'custom-thumbnail.jpg', + videoChannelId: servers[1].store.channel.id + } + }) + + playlistServer2Id2 = playlist.id + playlistServer2UUID2 = playlist.uuid + } + + for (const id of [ playlistServer2Id1, playlistServer2Id2 ]) { + await servers[1].playlists.addElement({ + playlistId: id, + attributes: { videoId: servers[1].store.videos[0].id, startTimestamp: 1, stopTimestamp: 2 } + }) + await servers[1].playlists.addElement({ + playlistId: id, + attributes: { videoId: servers[1].store.videos[1].id } + }) + } + + await waitJobs(servers) + await wait(3000) + + for (const server of [ servers[0], servers[1] ]) { + const body = await server.playlists.list({ start: 0, count: 5 }) + + const playlist2 = body.data.find(p => p.displayName === 'playlist 2') + expect(playlist2).to.not.be.undefined + await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', playlist2.thumbnailPath) + + const playlist3 = body.data.find(p => p.displayName === 'playlist 3') + expect(playlist3).to.not.be.undefined + await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', playlist3.thumbnailPath) + } + + const body = await servers[2].playlists.list({ start: 0, count: 5 }) + expect(body.data.find(p => p.displayName === 'playlist 2')).to.be.undefined + expect(body.data.find(p => p.displayName === 'playlist 3')).to.be.undefined + }) + + it('Should have the playlist on server 3 after a new follow', async function () { + this.timeout(30000) + + // Server 2 and server 3 follow each other + await doubleFollow(servers[1], servers[2]) + + const body = await servers[2].playlists.list({ start: 0, count: 5 }) + + const playlist2 = body.data.find(p => p.displayName === 'playlist 2') + expect(playlist2).to.not.be.undefined + await testImageGeneratedByFFmpeg(servers[2].url, 'thumbnail-playlist', playlist2.thumbnailPath) + + expect(body.data.find(p => p.displayName === 'playlist 3')).to.not.be.undefined + }) + }) + + describe('List playlists', function () { + + it('Should correctly list the playlists', async function () { + this.timeout(30000) + + { + const body = await servers[2].playlists.list({ start: 1, count: 2, sort: 'createdAt' }) + expect(body.total).to.equal(3) + + const data = body.data + expect(data).to.have.lengthOf(2) + expect(data[0].displayName).to.equal('playlist 2') + expect(data[1].displayName).to.equal('playlist 3') + } + + { + const body = await servers[2].playlists.list({ start: 1, count: 2, sort: '-createdAt' }) + expect(body.total).to.equal(3) + + const data = body.data + expect(data).to.have.lengthOf(2) + expect(data[0].displayName).to.equal('playlist 2') + expect(data[1].displayName).to.equal('my super playlist') + } + }) + + it('Should list video channel playlists', async function () { + this.timeout(30000) + + { + const body = await commands[0].listByChannel({ handle: 'root_channel', start: 0, count: 2, sort: '-createdAt' }) + expect(body.total).to.equal(1) + + const data = body.data + expect(data).to.have.lengthOf(1) + expect(data[0].displayName).to.equal('my super playlist') + } + }) + + it('Should list account playlists', async function () { + this.timeout(30000) + + { + const body = await servers[1].playlists.listByAccount({ handle: 'root', start: 1, count: 2, sort: '-createdAt' }) + expect(body.total).to.equal(2) + + const data = body.data + expect(data).to.have.lengthOf(1) + expect(data[0].displayName).to.equal('playlist 2') + } + + { + const body = await servers[1].playlists.listByAccount({ handle: 'root', start: 1, count: 2, sort: 'createdAt' }) + expect(body.total).to.equal(2) + + const data = body.data + expect(data).to.have.lengthOf(1) + expect(data[0].displayName).to.equal('playlist 3') + } + + { + const body = await servers[1].playlists.listByAccount({ handle: 'root', sort: 'createdAt', search: '3' }) + expect(body.total).to.equal(1) + + const data = body.data + expect(data).to.have.lengthOf(1) + expect(data[0].displayName).to.equal('playlist 3') + } + + { + const body = await servers[1].playlists.listByAccount({ handle: 'root', sort: 'createdAt', search: '4' }) + expect(body.total).to.equal(0) + + const data = body.data + expect(data).to.have.lengthOf(0) + } + }) + }) + + describe('Playlist rights', function () { + let unlistedPlaylist: VideoPlaylistCreateResult + let privatePlaylist: VideoPlaylistCreateResult + + before(async function () { + this.timeout(30000) + + { + unlistedPlaylist = await servers[1].playlists.create({ + attributes: { + displayName: 'playlist unlisted', + privacy: VideoPlaylistPrivacy.UNLISTED, + videoChannelId: servers[1].store.channel.id + } + }) + } + + { + privatePlaylist = await servers[1].playlists.create({ + attributes: { + displayName: 'playlist private', + privacy: VideoPlaylistPrivacy.PRIVATE + } + }) + } + + await waitJobs(servers) + await wait(3000) + }) + + it('Should not list unlisted or private playlists', async function () { + for (const server of servers) { + const results = [ + await server.playlists.listByAccount({ handle: 'root@' + servers[1].host, sort: '-createdAt' }), + await server.playlists.list({ start: 0, count: 2, sort: '-createdAt' }) + ] + + expect(results[0].total).to.equal(2) + expect(results[1].total).to.equal(3) + + for (const body of results) { + const data = body.data + expect(data).to.have.lengthOf(2) + expect(data[0].displayName).to.equal('playlist 3') + expect(data[1].displayName).to.equal('playlist 2') + } + } + }) + + it('Should not get unlisted playlist using only the id', async function () { + await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 }) + }) + + it('Should get unlisted playlist using uuid or shortUUID', async function () { + await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid }) + await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID }) + }) + + it('Should not get private playlist without token', async function () { + for (const id of [ privatePlaylist.id, privatePlaylist.uuid, privatePlaylist.shortUUID ]) { + await servers[1].playlists.get({ playlistId: id, expectedStatus: 401 }) + } + }) + + it('Should get private playlist with a token', async function () { + for (const id of [ privatePlaylist.id, privatePlaylist.uuid, privatePlaylist.shortUUID ]) { + await servers[1].playlists.get({ token: servers[1].accessToken, playlistId: id }) + } + }) + }) + + describe('Update playlists', function () { + + it('Should update a playlist', async function () { + this.timeout(30000) + + await servers[1].playlists.update({ + attributes: { + displayName: 'playlist 3 updated', + description: 'description updated', + privacy: VideoPlaylistPrivacy.UNLISTED, + thumbnailfile: 'custom-thumbnail.jpg', + videoChannelId: servers[1].store.channel.id + }, + playlistId: playlistServer2Id2 + }) + + await waitJobs(servers) + + for (const server of servers) { + const playlist = await server.playlists.get({ playlistId: playlistServer2UUID2 }) + + expect(playlist.displayName).to.equal('playlist 3 updated') + expect(playlist.description).to.equal('description updated') + + expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.UNLISTED) + expect(playlist.privacy.label).to.equal('Unlisted') + + expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR) + expect(playlist.type.label).to.equal('Regular') + + expect(playlist.videosLength).to.equal(2) + + expect(playlist.ownerAccount.name).to.equal('root') + expect(playlist.ownerAccount.displayName).to.equal('root') + expect(playlist.videoChannel.name).to.equal('root_channel') + expect(playlist.videoChannel.displayName).to.equal('Main root channel') + } + }) + }) + + describe('Element timestamps', function () { + + it('Should create a playlist containing different startTimestamp/endTimestamp videos', async function () { + this.timeout(30000) + + const addVideo = (attributes: any) => { + return commands[0].addElement({ playlistId: playlistServer1Id, attributes }) + } + + const playlistDisplayName = 'playlist 4' + const playlist = await commands[0].create({ + attributes: { + 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 }) + await addVideo({ videoId: servers[2].store.videos[1].uuid, startTimestamp: 35 }) + await addVideo({ videoId: servers[2].store.videos[2].uuid }) + { + const element = await addVideo({ videoId: servers[0].store.videos[3].uuid, stopTimestamp: 35 }) + playlistElementServer1Video4 = element.id + } + + { + const element = await addVideo({ videoId: servers[0].store.videos[4].uuid, startTimestamp: 45, stopTimestamp: 60 }) + playlistElementServer1Video5 = element.id + } + + { + const element = await addVideo({ videoId: nsfwVideoServer1, startTimestamp: 5 }) + playlistElementNSFW = element.id + + await addVideo({ videoId: nsfwVideoServer1, startTimestamp: 4 }) + await addVideo({ videoId: nsfwVideoServer1 }) + } + + await waitJobs(servers) + }) + + it('Should correctly list playlist videos', async function () { + this.timeout(30000) + + for (const server of servers) { + { + const body = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 10 }) + + expect(body.total).to.equal(8) + + const videoElements = body.data + expect(videoElements).to.have.lengthOf(8) + + expect(videoElements[0].video.name).to.equal('video 0 server 1') + expect(videoElements[0].position).to.equal(1) + expect(videoElements[0].startTimestamp).to.equal(15) + expect(videoElements[0].stopTimestamp).to.equal(28) + + expect(videoElements[1].video.name).to.equal('video 1 server 3') + expect(videoElements[1].position).to.equal(2) + expect(videoElements[1].startTimestamp).to.equal(35) + expect(videoElements[1].stopTimestamp).to.be.null + + expect(videoElements[2].video.name).to.equal('video 2 server 3') + expect(videoElements[2].position).to.equal(3) + expect(videoElements[2].startTimestamp).to.be.null + expect(videoElements[2].stopTimestamp).to.be.null + + expect(videoElements[3].video.name).to.equal('video 3 server 1') + expect(videoElements[3].position).to.equal(4) + expect(videoElements[3].startTimestamp).to.be.null + expect(videoElements[3].stopTimestamp).to.equal(35) + + expect(videoElements[4].video.name).to.equal('video 4 server 1') + expect(videoElements[4].position).to.equal(5) + expect(videoElements[4].startTimestamp).to.equal(45) + expect(videoElements[4].stopTimestamp).to.equal(60) + + expect(videoElements[5].video.name).to.equal('NSFW video') + expect(videoElements[5].position).to.equal(6) + expect(videoElements[5].startTimestamp).to.equal(5) + expect(videoElements[5].stopTimestamp).to.be.null + + expect(videoElements[6].video.name).to.equal('NSFW video') + expect(videoElements[6].position).to.equal(7) + expect(videoElements[6].startTimestamp).to.equal(4) + expect(videoElements[6].stopTimestamp).to.be.null + + expect(videoElements[7].video.name).to.equal('NSFW video') + expect(videoElements[7].position).to.equal(8) + expect(videoElements[7].startTimestamp).to.be.null + expect(videoElements[7].stopTimestamp).to.be.null + } + + { + const body = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 2 }) + expect(body.data).to.have.lengthOf(2) + } + } + }) + }) + + describe('Element type', function () { + let groupUser1: PeerTubeServer[] + let groupWithoutToken1: PeerTubeServer[] + let group1: PeerTubeServer[] + let group2: PeerTubeServer[] + + let video1: string + let video2: string + let video3: string + + before(async function () { + this.timeout(60000) + + groupUser1 = [ Object.assign({}, servers[0], { accessToken: userTokenServer1 }) ] + groupWithoutToken1 = [ Object.assign({}, servers[0], { accessToken: undefined }) ] + group1 = [ servers[0] ] + group2 = [ servers[1], servers[2] ] + + const playlist = await commands[0].create({ + token: userTokenServer1, + attributes: { + displayName: 'playlist 56', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: servers[0].store.channel.id + } + }) + + const playlistServer1Id2 = playlist.id + playlistServer1UUID2 = playlist.uuid + + const addVideo = (attributes: any) => { + return commands[0].addElement({ token: userTokenServer1, playlistId: playlistServer1Id2, attributes }) + } + + video1 = (await servers[0].videos.quickUpload({ name: 'video 89', token: userTokenServer1 })).uuid + video2 = (await servers[1].videos.quickUpload({ name: 'video 90' })).uuid + video3 = (await servers[0].videos.quickUpload({ name: 'video 91', nsfw: true })).uuid + + await waitJobs(servers) + + await addVideo({ videoId: video1, startTimestamp: 15, stopTimestamp: 28 }) + await addVideo({ videoId: video2, startTimestamp: 35 }) + await addVideo({ videoId: video3 }) + + await waitJobs(servers) + }) + + it('Should update the element type if the video is private/password protected', async function () { + this.timeout(20000) + + const name = 'video 89' + const position = 1 + + { + await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PRIVATE } }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3) + await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) + } + + { + await servers[0].videos.update({ + id: video1, + attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] } + }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3) + await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) + } + + { + await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + // We deleted the video, so even if we recreated it, the old entry is still deleted + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) + } + }) + + it('Should update the element type if the video is blacklisted', async function () { + this.timeout(20000) + + const name = 'video 89' + const position = 1 + + { + await servers[0].blacklist.add({ videoId: video1, reason: 'reason', unfederate: true }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3) + await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) + } + + { + await servers[0].blacklist.remove({ videoId: video1 }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + // We deleted the video (because unfederated), so even if we recreated it, the old entry is still deleted + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3) + } + }) + + it('Should update the element type if the account or server of the video is blocked', async function () { + this.timeout(90000) + + const command = servers[0].blocklist + + const name = 'video 90' + const position = 2 + + { + await command.addToMyBlocklist({ token: userTokenServer1, account: 'root@' + servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + + await command.removeFromMyBlocklist({ token: userTokenServer1, account: 'root@' + servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + } + + { + await command.addToMyBlocklist({ token: userTokenServer1, server: servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + + await command.removeFromMyBlocklist({ token: userTokenServer1, server: servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + } + + { + await command.addToServerBlocklist({ account: 'root@' + servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + + await command.removeFromServerBlocklist({ account: 'root@' + servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + } + + { + await command.addToServerBlocklist({ server: servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3) + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + + await command.removeFromServerBlocklist({ server: servers[1].host }) + await waitJobs(servers) + + await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3) + } + }) + }) + + describe('Managing playlist elements', function () { + + it('Should reorder the playlist', async function () { + this.timeout(30000) + + { + await commands[0].reorderElements({ + playlistId: playlistServer1Id, + attributes: { + startPosition: 2, + insertAfterPosition: 3 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const body = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 10 }) + const names = body.data.map(v => v.video.name) + + expect(names).to.deep.equal([ + 'video 0 server 1', + 'video 2 server 3', + 'video 1 server 3', + 'video 3 server 1', + 'video 4 server 1', + 'NSFW video', + 'NSFW video', + 'NSFW video' + ]) + } + } + + { + await commands[0].reorderElements({ + playlistId: playlistServer1Id, + attributes: { + startPosition: 1, + reorderLength: 3, + insertAfterPosition: 4 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const body = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 10 }) + const names = body.data.map(v => v.video.name) + + expect(names).to.deep.equal([ + 'video 3 server 1', + 'video 0 server 1', + 'video 2 server 3', + 'video 1 server 3', + 'video 4 server 1', + 'NSFW video', + 'NSFW video', + 'NSFW video' + ]) + } + } + + { + await commands[0].reorderElements({ + playlistId: playlistServer1Id, + attributes: { + startPosition: 6, + insertAfterPosition: 3 + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const { data: elements } = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 10 }) + const names = elements.map(v => v.video.name) + + expect(names).to.deep.equal([ + 'video 3 server 1', + 'video 0 server 1', + 'video 2 server 3', + 'NSFW video', + 'video 1 server 3', + 'video 4 server 1', + 'NSFW video', + 'NSFW video' + ]) + + for (let i = 1; i <= elements.length; i++) { + expect(elements[i - 1].position).to.equal(i) + } + } + } + }) + + it('Should update startTimestamp/endTimestamp of some elements', async function () { + this.timeout(30000) + + await commands[0].updateElement({ + playlistId: playlistServer1Id, + elementId: playlistElementServer1Video4, + attributes: { + startTimestamp: 1 + } + }) + + await commands[0].updateElement({ + playlistId: playlistServer1Id, + elementId: playlistElementServer1Video5, + attributes: { + stopTimestamp: null + } + }) + + await waitJobs(servers) + + for (const server of servers) { + const { data: elements } = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 10 }) + + expect(elements[0].video.name).to.equal('video 3 server 1') + expect(elements[0].position).to.equal(1) + expect(elements[0].startTimestamp).to.equal(1) + expect(elements[0].stopTimestamp).to.equal(35) + + expect(elements[5].video.name).to.equal('video 4 server 1') + expect(elements[5].position).to.equal(6) + expect(elements[5].startTimestamp).to.equal(45) + expect(elements[5].stopTimestamp).to.be.null + } + }) + + it('Should check videos existence in my playlist', async function () { + const videoIds = [ + servers[0].store.videos[0].id, + 42000, + servers[0].store.videos[3].id, + 43000, + servers[0].store.videos[4].id + ] + const obj = await commands[0].videosExist({ videoIds }) + + { + 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) + } + + { + 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) + } + + { + 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) + } + + expect(obj[42000]).to.have.lengthOf(0) + expect(obj[43000]).to.have.lengthOf(0) + }) + + it('Should automatically update updatedAt field of playlists', async function () { + const server = servers[1] + const videoId = servers[1].store.videos[5].id + + async function getPlaylistNames () { + const { data } = await server.playlists.listByAccount({ token: server.accessToken, handle: 'root', sort: '-updatedAt' }) + + return data.map(p => p.displayName) + } + + const attributes = { videoId } + const element1 = await server.playlists.addElement({ playlistId: playlistServer2Id1, attributes }) + const element2 = await server.playlists.addElement({ playlistId: playlistServer2Id2, attributes }) + + const names1 = await getPlaylistNames() + expect(names1[0]).to.equal('playlist 3 updated') + expect(names1[1]).to.equal('playlist 2') + + await server.playlists.removeElement({ playlistId: playlistServer2Id1, elementId: element1.id }) + + const names2 = await getPlaylistNames() + expect(names2[0]).to.equal('playlist 2') + expect(names2[1]).to.equal('playlist 3 updated') + + await server.playlists.removeElement({ playlistId: playlistServer2Id2, elementId: element2.id }) + + const names3 = await getPlaylistNames() + expect(names3[0]).to.equal('playlist 3 updated') + expect(names3[1]).to.equal('playlist 2') + }) + + it('Should delete some elements', async function () { + this.timeout(30000) + + await commands[0].removeElement({ playlistId: playlistServer1Id, elementId: playlistElementServer1Video4 }) + await commands[0].removeElement({ playlistId: playlistServer1Id, elementId: playlistElementNSFW }) + + await waitJobs(servers) + + for (const server of servers) { + const body = await server.playlists.listVideos({ playlistId: playlistServer1UUID, start: 0, count: 10 }) + expect(body.total).to.equal(6) + + const elements = body.data + expect(elements).to.have.lengthOf(6) + + expect(elements[0].video.name).to.equal('video 0 server 1') + expect(elements[0].position).to.equal(1) + + expect(elements[1].video.name).to.equal('video 2 server 3') + expect(elements[1].position).to.equal(2) + + expect(elements[2].video.name).to.equal('video 1 server 3') + expect(elements[2].position).to.equal(3) + + expect(elements[3].video.name).to.equal('video 4 server 1') + expect(elements[3].position).to.equal(4) + + expect(elements[4].video.name).to.equal('NSFW video') + expect(elements[4].position).to.equal(5) + + expect(elements[5].video.name).to.equal('NSFW video') + expect(elements[5].position).to.equal(6) + } + }) + + it('Should be able to create a public playlist, and set it to private', async function () { + this.timeout(30000) + + const videoPlaylistIds = await commands[0].create({ + attributes: { + displayName: 'my super public playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: servers[0].store.channel.id + } + }) + + await waitJobs(servers) + + for (const server of servers) { + await server.playlists.get({ playlistId: videoPlaylistIds.uuid, expectedStatus: HttpStatusCode.OK_200 }) + } + + const attributes = { privacy: VideoPlaylistPrivacy.PRIVATE } + await commands[0].update({ playlistId: videoPlaylistIds.id, attributes }) + + await waitJobs(servers) + + for (const server of [ servers[1], servers[2] ]) { + await server.playlists.get({ playlistId: videoPlaylistIds.uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + + await commands[0].get({ playlistId: videoPlaylistIds.uuid, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await commands[0].get({ token: servers[0].accessToken, playlistId: videoPlaylistIds.uuid, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('Playlist deletion', function () { + + it('Should delete the playlist on server 1 and delete on server 2 and 3', async function () { + this.timeout(30000) + + await commands[0].delete({ playlistId: playlistServer1Id }) + + await waitJobs(servers) + + for (const server of servers) { + await server.playlists.get({ playlistId: playlistServer1UUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + } + }) + + it('Should have deleted the thumbnail on server 1, 2 and 3', async function () { + this.timeout(30000) + + for (const server of servers) { + await checkPlaylistFilesWereRemoved(playlistServer1UUID, server) + } + }) + + it('Should unfollow servers 1 and 2 and hide their playlists', async function () { + this.timeout(30000) + + const finder = (data: VideoPlaylist[]) => data.find(p => p.displayName === 'my super playlist') + + { + const body = await servers[2].playlists.list({ start: 0, count: 5 }) + expect(body.total).to.equal(3) + + expect(finder(body.data)).to.not.be.undefined + } + + await servers[2].follows.unfollow({ target: servers[0] }) + + { + const body = await servers[2].playlists.list({ start: 0, count: 5 }) + expect(body.total).to.equal(1) + + expect(finder(body.data)).to.be.undefined + } + }) + + it('Should delete a channel and put the associated playlist in private mode', async function () { + this.timeout(30000) + + const channel = await servers[0].channels.create({ attributes: { name: 'super_channel', displayName: 'super channel' } }) + + const playlistCreated = await commands[0].create({ + attributes: { + displayName: 'channel playlist', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: channel.id + } + }) + + await waitJobs(servers) + + await servers[0].channels.delete({ channelName: 'super_channel' }) + + await waitJobs(servers) + + const body = await commands[0].get({ token: servers[0].accessToken, playlistId: playlistCreated.uuid }) + expect(body.displayName).to.equal('channel playlist') + expect(body.privacy.id).to.equal(VideoPlaylistPrivacy.PRIVATE) + + await servers[1].playlists.get({ playlistId: playlistCreated.uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should delete an account and delete its playlists', async function () { + this.timeout(30000) + + const { userId, token } = await servers[0].users.generate('user_1') + + const { videoChannels } = await servers[0].users.getMyInfo({ token }) + const userChannel = videoChannels[0] + + await commands[0].create({ + attributes: { + displayName: 'playlist to be deleted', + privacy: VideoPlaylistPrivacy.PUBLIC, + videoChannelId: userChannel.id + } + }) + + await waitJobs(servers) + + const finder = (data: VideoPlaylist[]) => data.find(p => p.displayName === 'playlist to be deleted') + + { + for (const server of [ servers[0], servers[1] ]) { + const body = await server.playlists.list({ start: 0, count: 15 }) + + expect(finder(body.data)).to.not.be.undefined + } + } + + await servers[0].users.remove({ userId }) + await waitJobs(servers) + + { + for (const server of [ servers[0], servers[1] ]) { + const body = await server.playlists.list({ start: 0, count: 15 }) + + expect(finder(body.data)).to.be.undefined + } + } + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-privacy.ts b/packages/tests/src/api/videos/video-privacy.ts new file mode 100644 index 000000000..9171463a4 --- /dev/null +++ b/packages/tests/src/api/videos/video-privacy.ts @@ -0,0 +1,294 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test video privacy', function () { + const servers: PeerTubeServer[] = [] + let anotherUserToken: string + + let privateVideoId: number + let privateVideoUUID: string + + let internalVideoId: number + let internalVideoUUID: string + + let unlistedVideo: VideoCreateResult + let nonFederatedUnlistedVideoUUID: string + + let now: number + + const dontFederateUnlistedConfig = { + federation: { + videos: { + federate_unlisted: false + } + } + } + + before(async function () { + this.timeout(50000) + + // Run servers + servers.push(await createSingleServer(1, dontFederateUnlistedConfig)) + servers.push(await createSingleServer(2)) + + // Get the access tokens + await setAccessTokensToServers(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + describe('Private and internal videos', function () { + + it('Should upload a private and internal videos on server 1', async function () { + this.timeout(50000) + + for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { + const attributes = { privacy } + await servers[0].videos.upload({ attributes }) + } + + await waitJobs(servers) + }) + + it('Should not have these private and internal videos on server 2', async function () { + const { total, data } = await servers[1].videos.list() + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + }) + + it('Should not list the private and internal videos for an unauthenticated user on server 1', async function () { + const { total, data } = await servers[0].videos.list() + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + }) + + it('Should not list the private video and list the internal video for an authenticated user on server 1', async function () { + const { total, data } = await servers[0].videos.listWithToken() + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + expect(data[0].privacy.id).to.equal(VideoPrivacy.INTERNAL) + }) + + it('Should list my (private and internal) videos', async function () { + const { total, data } = await servers[0].videos.listMyVideos() + + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + const privateVideo = data.find(v => v.privacy.id === VideoPrivacy.PRIVATE) + privateVideoId = privateVideo.id + privateVideoUUID = privateVideo.uuid + + const internalVideo = data.find(v => v.privacy.id === VideoPrivacy.INTERNAL) + internalVideoId = internalVideo.id + internalVideoUUID = internalVideo.uuid + }) + + it('Should not be able to watch the private/internal video with non authenticated user', async function () { + await servers[0].videos.get({ id: privateVideoUUID, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + await servers[0].videos.get({ id: internalVideoUUID, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should not be able to watch the private video with another user', async function () { + const user = { + username: 'hello', + password: 'super password' + } + await servers[0].users.create({ username: user.username, password: user.password }) + + anotherUserToken = await servers[0].login.getAccessToken(user) + + await servers[0].videos.getWithToken({ + token: anotherUserToken, + id: privateVideoUUID, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should be able to watch the internal video with another user', async function () { + await servers[0].videos.getWithToken({ token: anotherUserToken, id: internalVideoUUID }) + }) + + it('Should be able to watch the private video with the correct user', async function () { + await servers[0].videos.getWithToken({ id: privateVideoUUID }) + }) + }) + + describe('Unlisted videos', function () { + + it('Should upload an unlisted video on server 2', async function () { + this.timeout(120000) + + const attributes = { + name: 'unlisted video', + privacy: VideoPrivacy.UNLISTED + } + await servers[1].videos.upload({ attributes }) + + // Server 2 has transcoding enabled + await waitJobs(servers) + }) + + it('Should not have this unlisted video listed on server 1 and 2', async function () { + for (const server of servers) { + const { total, data } = await server.videos.list() + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } + }) + + it('Should list my (unlisted) videos', async function () { + const { total, data } = await servers[1].videos.listMyVideos() + + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + unlistedVideo = data[0] + }) + + it('Should not be able to get this unlisted video using its id', async function () { + await servers[1].videos.get({ id: unlistedVideo.id, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should be able to get this unlisted video using its uuid/shortUUID', async function () { + for (const server of servers) { + for (const id of [ unlistedVideo.uuid, unlistedVideo.shortUUID ]) { + const video = await server.videos.get({ id }) + + expect(video.name).to.equal('unlisted video') + } + } + }) + + it('Should upload a non-federating unlisted video to server 1', async function () { + this.timeout(30000) + + const attributes = { + name: 'unlisted video', + privacy: VideoPrivacy.UNLISTED + } + await servers[0].videos.upload({ attributes }) + + await waitJobs(servers) + }) + + it('Should list my new unlisted video', async function () { + const { total, data } = await servers[0].videos.listMyVideos() + + expect(total).to.equal(3) + expect(data).to.have.lengthOf(3) + + nonFederatedUnlistedVideoUUID = data[0].uuid + }) + + it('Should be able to get non-federated unlisted video from origin', async function () { + const video = await servers[0].videos.get({ id: nonFederatedUnlistedVideoUUID }) + + expect(video.name).to.equal('unlisted video') + }) + + it('Should not be able to get non-federated unlisted video from federated server', async function () { + await servers[1].videos.get({ id: nonFederatedUnlistedVideoUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + }) + + describe('Privacy update', function () { + + it('Should update the private and internal videos to public on server 1', async function () { + this.timeout(100000) + + now = Date.now() + + { + const attributes = { + name: 'private video becomes public', + privacy: VideoPrivacy.PUBLIC + } + + await servers[0].videos.update({ id: privateVideoId, attributes }) + } + + { + const attributes = { + name: 'internal video becomes public', + privacy: VideoPrivacy.PUBLIC + } + await servers[0].videos.update({ id: internalVideoId, attributes }) + } + + await wait(10000) + await waitJobs(servers) + }) + + it('Should have this new public video listed on server 1 and 2', async function () { + for (const server of servers) { + const { total, data } = await server.videos.list() + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + const privateVideo = data.find(v => v.name === 'private video becomes public') + const internalVideo = data.find(v => v.name === 'internal video becomes public') + + expect(privateVideo).to.not.be.undefined + expect(internalVideo).to.not.be.undefined + + expect(new Date(privateVideo.publishedAt).getTime()).to.be.at.least(now) + // We don't change the publish date of internal videos + expect(new Date(internalVideo.publishedAt).getTime()).to.be.below(now) + + expect(privateVideo.privacy.id).to.equal(VideoPrivacy.PUBLIC) + expect(internalVideo.privacy.id).to.equal(VideoPrivacy.PUBLIC) + } + }) + + it('Should set these videos as private and internal', async function () { + await servers[0].videos.update({ id: internalVideoId, attributes: { privacy: VideoPrivacy.PRIVATE } }) + await servers[0].videos.update({ id: privateVideoId, attributes: { privacy: VideoPrivacy.INTERNAL } }) + + await waitJobs(servers) + + for (const server of servers) { + const { total, data } = await server.videos.list() + + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + } + + { + const { total, data } = await servers[0].videos.listMyVideos() + expect(total).to.equal(3) + expect(data).to.have.lengthOf(3) + + const privateVideo = data.find(v => v.name === 'private video becomes public') + const internalVideo = data.find(v => v.name === 'internal video becomes public') + + expect(privateVideo).to.not.be.undefined + expect(internalVideo).to.not.be.undefined + + expect(privateVideo.privacy.id).to.equal(VideoPrivacy.INTERNAL) + expect(internalVideo.privacy.id).to.equal(VideoPrivacy.PRIVATE) + } + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-schedule-update.ts b/packages/tests/src/api/videos/video-schedule-update.ts new file mode 100644 index 000000000..96d71933e --- /dev/null +++ b/packages/tests/src/api/videos/video-schedule-update.ts @@ -0,0 +1,155 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +function in10Seconds () { + const now = new Date() + now.setSeconds(now.getSeconds() + 10) + + return now +} + +describe('Test video update scheduler', function () { + let servers: PeerTubeServer[] = [] + let video2UUID: string + + before(async function () { + this.timeout(30000) + + // Run servers + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + }) + + it('Should upload a video and schedule an update in 10 seconds', async function () { + const attributes = { + name: 'video 1', + privacy: VideoPrivacy.PRIVATE, + scheduleUpdate: { + updateAt: in10Seconds().toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + + await servers[0].videos.upload({ attributes }) + + await waitJobs(servers) + }) + + it('Should not list the video (in privacy mode)', async function () { + for (const server of servers) { + const { total } = await server.videos.list() + + expect(total).to.equal(0) + } + }) + + it('Should have my scheduled video in my account videos', async function () { + const { total, data } = await servers[0].videos.listMyVideos() + expect(total).to.equal(1) + + const videoFromList = data[0] + const videoFromGet = await servers[0].videos.getWithToken({ id: videoFromList.uuid }) + + for (const video of [ videoFromList, videoFromGet ]) { + expect(video.name).to.equal('video 1') + expect(video.privacy.id).to.equal(VideoPrivacy.PRIVATE) + expect(new Date(video.scheduledUpdate.updateAt)).to.be.above(new Date()) + expect(video.scheduledUpdate.privacy).to.equal(VideoPrivacy.PUBLIC) + } + }) + + it('Should wait some seconds and have the video in public privacy', async function () { + this.timeout(50000) + + await wait(15000) + await waitJobs(servers) + + for (const server of servers) { + const { total, data } = await server.videos.list() + + expect(total).to.equal(1) + expect(data[0].name).to.equal('video 1') + } + }) + + it('Should upload a video without scheduling an update', async function () { + const attributes = { + name: 'video 2', + privacy: VideoPrivacy.PRIVATE + } + + const { uuid } = await servers[0].videos.upload({ attributes }) + video2UUID = uuid + + await waitJobs(servers) + }) + + it('Should update a video by scheduling an update', async function () { + const attributes = { + name: 'video 2 updated', + scheduleUpdate: { + updateAt: in10Seconds().toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + + await servers[0].videos.update({ id: video2UUID, attributes }) + await waitJobs(servers) + }) + + it('Should not display the updated video', async function () { + for (const server of servers) { + const { total } = await server.videos.list() + + expect(total).to.equal(1) + } + }) + + it('Should have my scheduled updated video in my account videos', async function () { + const { total, data } = await servers[0].videos.listMyVideos() + expect(total).to.equal(2) + + const video = data.find(v => v.uuid === video2UUID) + expect(video).not.to.be.undefined + + expect(video.name).to.equal('video 2 updated') + expect(video.privacy.id).to.equal(VideoPrivacy.PRIVATE) + + expect(new Date(video.scheduledUpdate.updateAt)).to.be.above(new Date()) + expect(video.scheduledUpdate.privacy).to.equal(VideoPrivacy.PUBLIC) + }) + + it('Should wait some seconds and have the updated video in public privacy', async function () { + this.timeout(20000) + + await wait(15000) + await waitJobs(servers) + + for (const server of servers) { + const { total, data } = await server.videos.list() + expect(total).to.equal(2) + + const video = data.find(v => v.uuid === video2UUID) + expect(video).not.to.be.undefined + expect(video.name).to.equal('video 2 updated') + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-source.ts b/packages/tests/src/api/videos/video-source.ts new file mode 100644 index 000000000..efe8c3802 --- /dev/null +++ b/packages/tests/src/api/videos/video-source.ts @@ -0,0 +1,448 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ +import { expect } from 'chai' +import { getAllFiles } from '@peertube/peertube-core-utils' +import { HttpStatusCode } from '@peertube/peertube-models' +import { expectStartWith } from '@tests/shared/checks.js' +import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + makeRawRequest, + ObjectStorageCommand, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test a video file replacement', function () { + let servers: PeerTubeServer[] = [] + + let replaceDate: Date + let userToken: string + let uuid: string + + before(async function () { + this.timeout(50000) + + servers = await createMultipleServers(2) + + // Get the access tokens + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultAccountAvatar(servers) + + await servers[0].config.enableFileUpdate() + + userToken = await servers[0].users.generateUserAndToken('user1') + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + }) + + describe('Getting latest video source', () => { + const fixture = 'video_short.webm' + const uuids: string[] = [] + + it('Should get the source filename with legacy upload', async function () { + this.timeout(30000) + + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video', fixture }, mode: 'legacy' }) + uuids.push(uuid) + + const source = await servers[0].videos.getSource({ id: uuid }) + expect(source.filename).to.equal(fixture) + }) + + it('Should get the source filename with resumable upload', async function () { + this.timeout(30000) + + const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video', fixture }, mode: 'resumable' }) + uuids.push(uuid) + + const source = await servers[0].videos.getSource({ id: uuid }) + expect(source.filename).to.equal(fixture) + }) + + after(async function () { + this.timeout(60000) + + for (const uuid of uuids) { + await servers[0].videos.remove({ id: uuid }) + } + + await waitJobs(servers) + }) + }) + + describe('Updating video source', function () { + + describe('Filesystem', function () { + + it('Should replace a video file with transcoding disabled', async function () { + this.timeout(120000) + + await servers[0].config.disableTranscoding() + + const { uuid } = await servers[0].videos.quickUpload({ name: 'fs without transcoding', fixture: 'video_short_720p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(1) + expect(files[0].resolution.id).to.equal(720) + } + + await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(1) + expect(files[0].resolution.id).to.equal(360) + } + }) + + it('Should replace a video file with transcoding enabled', async function () { + this.timeout(120000) + + const previousPaths: string[] = [] + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) + + const { uuid: videoUUID } = await servers[0].videos.quickUpload({ name: 'fs with transcoding', fixture: 'video_short_720p.mp4' }) + uuid = videoUUID + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + expect(video.inputFileUpdatedAt).to.be.null + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(6 * 2) + + // Grab old paths to ensure we'll regenerate + + previousPaths.push(video.previewPath) + previousPaths.push(video.thumbnailPath) + + for (const file of files) { + previousPaths.push(file.fileUrl) + previousPaths.push(file.torrentUrl) + previousPaths.push(file.metadataUrl) + + const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl }) + previousPaths.push(JSON.stringify(metadata)) + } + + const { storyboards } = await server.storyboard.list({ id: uuid }) + for (const s of storyboards) { + previousPaths.push(s.storyboardPath) + } + } + + replaceDate = new Date() + + await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + expect(video.inputFileUpdatedAt).to.not.be.null + expect(new Date(video.inputFileUpdatedAt)).to.be.above(replaceDate) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(4 * 2) + + expect(previousPaths).to.not.include(video.previewPath) + expect(previousPaths).to.not.include(video.thumbnailPath) + + await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + + for (const file of files) { + expect(previousPaths).to.not.include(file.fileUrl) + expect(previousPaths).to.not.include(file.torrentUrl) + expect(previousPaths).to.not.include(file.metadataUrl) + + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: file.torrentUrl, expectedStatus: HttpStatusCode.OK_200 }) + + const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl }) + expect(previousPaths).to.not.include(JSON.stringify(metadata)) + } + + const { storyboards } = await server.storyboard.list({ id: uuid }) + for (const s of storyboards) { + expect(previousPaths).to.not.include(s.storyboardPath) + + await makeGetRequest({ url: server.url, path: s.storyboardPath, expectedStatus: HttpStatusCode.OK_200 }) + } + } + + await servers[0].config.enableMinimumTranscoding() + }) + + it('Should have cleaned up old files', async function () { + { + const count = await servers[0].servers.countFiles('storyboards') + expect(count).to.equal(2) + } + + { + const count = await servers[0].servers.countFiles('web-videos') + expect(count).to.equal(5 + 1) // +1 for private directory + } + + { + const count = await servers[0].servers.countFiles('streaming-playlists/hls') + expect(count).to.equal(1 + 1) // +1 for private directory + } + + { + const count = await servers[0].servers.countFiles('torrents') + expect(count).to.equal(9) + } + }) + + it('Should have the correct source input', async function () { + const source = await servers[0].videos.getSource({ id: uuid }) + + expect(source.filename).to.equal('video_short_360p.mp4') + expect(new Date(source.createdAt)).to.be.above(replaceDate) + }) + + it('Should not have regenerated miniatures that were previously uploaded', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.upload({ + attributes: { + name: 'custom miniatures', + thumbnailfile: 'custom-thumbnail.jpg', + previewfile: 'custom-preview.jpg' + } + }) + + await waitJobs(servers) + + const previousPaths: string[] = [] + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + previousPaths.push(video.previewPath) + previousPaths.push(video.thumbnailPath) + + await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + } + + await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + expect(previousPaths).to.include(video.previewPath) + expect(previousPaths).to.include(video.thumbnailPath) + + await makeGetRequest({ url: server.url, path: video.previewPath, expectedStatus: HttpStatusCode.OK_200 }) + await makeGetRequest({ url: server.url, path: video.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + }) + + describe('Autoblacklist', function () { + + function updateAutoBlacklist (enabled: boolean) { + return servers[0].config.updateExistingSubConfig({ + newConfig: { + autoBlacklist: { + videos: { + ofUsers: { + enabled + } + } + } + } + }) + } + + async function expectBlacklist (uuid: string, value: boolean) { + const video = await servers[0].videos.getWithToken({ id: uuid }) + + expect(video.blacklisted).to.equal(value) + } + + before(async function () { + await updateAutoBlacklist(true) + }) + + it('Should auto blacklist an unblacklisted video after file replacement', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.quickUpload({ token: userToken, name: 'user video' }) + await waitJobs(servers) + await expectBlacklist(uuid, true) + + await servers[0].blacklist.remove({ videoId: uuid }) + await expectBlacklist(uuid, false) + + await servers[0].videos.replaceSourceFile({ videoId: uuid, token: userToken, fixture: 'video_short_360p.mp4' }) + await waitJobs(servers) + + await expectBlacklist(uuid, true) + }) + + it('Should auto blacklist an already blacklisted video after file replacement', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.quickUpload({ token: userToken, name: 'user video' }) + await waitJobs(servers) + await expectBlacklist(uuid, true) + + await servers[0].videos.replaceSourceFile({ videoId: uuid, token: userToken, fixture: 'video_short_360p.mp4' }) + await waitJobs(servers) + + await expectBlacklist(uuid, true) + }) + + it('Should not auto blacklist if auto blacklist has been disabled between the upload and the replacement', async function () { + this.timeout(120000) + + const { uuid } = await servers[0].videos.quickUpload({ token: userToken, name: 'user video' }) + await waitJobs(servers) + await expectBlacklist(uuid, true) + + await servers[0].blacklist.remove({ videoId: uuid }) + await expectBlacklist(uuid, false) + + await updateAutoBlacklist(false) + + await servers[0].videos.replaceSourceFile({ videoId: uuid, token: userToken, fixture: 'video_short1.webm' }) + await waitJobs(servers) + + await expectBlacklist(uuid, false) + }) + }) + + describe('With object storage enabled', function () { + if (areMockObjectStorageTestsDisabled()) return + + const objectStorage = new ObjectStorageCommand() + + before(async function () { + this.timeout(120000) + + const configOverride = objectStorage.getDefaultMockConfig() + await objectStorage.prepareDefaultMockBuckets() + + await servers[0].kill() + await servers[0].run(configOverride) + }) + + it('Should replace a video file with transcoding disabled', async function () { + this.timeout(120000) + + await servers[0].config.disableTranscoding() + + const { uuid } = await servers[0].videos.quickUpload({ + name: 'object storage without transcoding', + fixture: 'video_short_720p.mp4' + }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(1) + expect(files[0].resolution.id).to.equal(720) + expectStartWith(files[0].fileUrl, objectStorage.getMockWebVideosBaseUrl()) + } + + await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(1) + expect(files[0].resolution.id).to.equal(360) + expectStartWith(files[0].fileUrl, objectStorage.getMockWebVideosBaseUrl()) + } + }) + + it('Should replace a video file with transcoding enabled', async function () { + this.timeout(120000) + + const previousPaths: string[] = [] + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) + + const { uuid: videoUUID } = await servers[0].videos.quickUpload({ + name: 'object storage with transcoding', + fixture: 'video_short_360p.mp4' + }) + uuid = videoUUID + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(4 * 2) + + for (const file of files) { + previousPaths.push(file.fileUrl) + } + + for (const file of video.files) { + expectStartWith(file.fileUrl, objectStorage.getMockWebVideosBaseUrl()) + } + + for (const file of video.streamingPlaylists[0].files) { + expectStartWith(file.fileUrl, objectStorage.getMockPlaylistBaseUrl()) + } + } + + await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_240p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + const files = getAllFiles(video) + expect(files).to.have.lengthOf(3 * 2) + + for (const file of files) { + expect(previousPaths).to.not.include(file.fileUrl) + } + + for (const file of video.files) { + expectStartWith(file.fileUrl, objectStorage.getMockWebVideosBaseUrl()) + } + + for (const file of video.streamingPlaylists[0].files) { + expectStartWith(file.fileUrl, objectStorage.getMockPlaylistBaseUrl()) + } + } + }) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/video-static-file-privacy.ts b/packages/tests/src/api/videos/video-static-file-privacy.ts new file mode 100644 index 000000000..7c8d14815 --- /dev/null +++ b/packages/tests/src/api/videos/video-static-file-privacy.ts @@ -0,0 +1,602 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { decode } from 'magnet-uri' +import { getAllFiles, wait } from '@peertube/peertube-core-utils' +import { HttpStatusCode, HttpStatusCodeType, LiveVideo, VideoDetails, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + findExternalSavedVideo, + makeRawRequest, + PeerTubeServer, + sendRTMPStream, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs +} from '@peertube/peertube-server-commands' +import { expectStartWith } from '@tests/shared/checks.js' +import { checkVideoFileTokenReinjection } from '@tests/shared/streaming-playlists.js' +import { parseTorrentVideo } from '@tests/shared/webtorrent.js' + +describe('Test video static file privacy', function () { + let server: PeerTubeServer + let userToken: string + + before(async function () { + this.timeout(50000) + + server = await createSingleServer(1) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + userToken = await server.users.generateUserAndToken('user1') + }) + + describe('VOD static file path', function () { + + function runSuite () { + + async function checkPrivateFiles (uuid: string) { + const video = await server.videos.getWithToken({ id: uuid }) + + for (const file of video.files) { + expect(file.fileDownloadUrl).to.not.include('/private/') + expectStartWith(file.fileUrl, server.url + '/static/web-videos/private/') + + const torrent = await parseTorrentVideo(server, file) + expect(torrent.urlList).to.have.lengthOf(0) + + const magnet = decode(file.magnetUri) + expect(magnet.urlList).to.have.lengthOf(0) + + await makeRawRequest({ url: file.fileUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + } + + const hls = video.streamingPlaylists[0] + if (hls) { + expectStartWith(hls.playlistUrl, server.url + '/static/streaming-playlists/hls/private/') + expectStartWith(hls.segmentsSha256Url, server.url + '/static/streaming-playlists/hls/private/') + + await makeRawRequest({ url: hls.playlistUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: hls.segmentsSha256Url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + } + } + + async function checkPublicFiles (uuid: string) { + const video = await server.videos.get({ id: uuid }) + + for (const file of getAllFiles(video)) { + expect(file.fileDownloadUrl).to.not.include('/private/') + expect(file.fileUrl).to.not.include('/private/') + + const torrent = await parseTorrentVideo(server, file) + expect(torrent.urlList[0]).to.not.include('private') + + const magnet = decode(file.magnetUri) + expect(magnet.urlList[0]).to.not.include('private') + + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: torrent.urlList[0], expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: magnet.urlList[0], expectedStatus: HttpStatusCode.OK_200 }) + } + + const hls = video.streamingPlaylists[0] + if (hls) { + expect(hls.playlistUrl).to.not.include('private') + expect(hls.segmentsSha256Url).to.not.include('private') + + await makeRawRequest({ url: hls.playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: hls.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) + } + } + + it('Should upload a private/internal/password protected video and have a private static path', async function () { + this.timeout(120000) + + for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy }) + await waitJobs([ server ]) + + await checkPrivateFiles(uuid) + } + + const { uuid } = await server.videos.quickUpload({ + name: 'video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ 'my super password' ] + }) + await waitJobs([ server ]) + + await checkPrivateFiles(uuid) + }) + + it('Should upload a public video and update it as private/internal to have a private static path', async function () { + this.timeout(120000) + + for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PUBLIC }) + await waitJobs([ server ]) + + await server.videos.update({ id: uuid, attributes: { privacy } }) + await waitJobs([ server ]) + + await checkPrivateFiles(uuid) + } + }) + + it('Should upload a private video and update it to unlisted to have a public static path', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + await waitJobs([ server ]) + + await server.videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.UNLISTED } }) + await waitJobs([ server ]) + + await checkPublicFiles(uuid) + }) + + it('Should upload an internal video and update it to public to have a public static path', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.INTERNAL }) + await waitJobs([ server ]) + + await server.videos.update({ id: uuid, attributes: { privacy: VideoPrivacy.PUBLIC } }) + await waitJobs([ server ]) + + await checkPublicFiles(uuid) + }) + + it('Should upload an internal video and schedule a public publish', async function () { + this.timeout(120000) + + const attributes = { + name: 'video', + privacy: VideoPrivacy.PRIVATE, + scheduleUpdate: { + updateAt: new Date(Date.now() + 1000).toISOString(), + privacy: VideoPrivacy.PUBLIC + } + } + + const { uuid } = await server.videos.upload({ attributes }) + + await waitJobs([ server ]) + await wait(1000) + await server.debug.sendCommand({ body: { command: 'process-update-videos-scheduler' } }) + + await waitJobs([ server ]) + + await checkPublicFiles(uuid) + }) + } + + describe('Without transcoding', function () { + runSuite() + }) + + describe('With transcoding', function () { + + before(async function () { + await server.config.enableMinimumTranscoding() + }) + + runSuite() + }) + }) + + describe('VOD static file right check', function () { + let unrelatedFileToken: string + + async function checkVideoFiles (options: { + id: string + expectedStatus: HttpStatusCodeType + token: string + videoFileToken: string + videoPassword?: string + }) { + const { id, expectedStatus, token, videoFileToken, videoPassword } = options + + const video = await server.videos.getWithToken({ id }) + + for (const file of getAllFiles(video)) { + await makeRawRequest({ url: file.fileUrl, token, expectedStatus }) + await makeRawRequest({ url: file.fileDownloadUrl, token, expectedStatus }) + + await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus }) + await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus }) + + if (videoPassword) { + const headers = { 'x-peertube-video-password': videoPassword } + await makeRawRequest({ url: file.fileUrl, headers, expectedStatus }) + await makeRawRequest({ url: file.fileDownloadUrl, headers, expectedStatus }) + } + } + + const hls = video.streamingPlaylists[0] + await makeRawRequest({ url: hls.playlistUrl, token, expectedStatus }) + await makeRawRequest({ url: hls.segmentsSha256Url, token, expectedStatus }) + + await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus }) + await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus }) + + if (videoPassword) { + const headers = { 'x-peertube-video-password': videoPassword } + await makeRawRequest({ url: hls.playlistUrl, token: null, headers, expectedStatus }) + await makeRawRequest({ url: hls.segmentsSha256Url, token: null, headers, expectedStatus }) + } + } + + before(async function () { + await server.config.enableMinimumTranscoding() + + const { uuid } = await server.videos.quickUpload({ name: 'another video' }) + unrelatedFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + }) + + it('Should not be able to access a private video files without OAuth token and file token', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + await waitJobs([ server ]) + + await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null }) + }) + + it('Should not be able to access password protected video files without OAuth token, file token and password', async function () { + this.timeout(120000) + const videoPassword = 'my super password' + + const { uuid } = await server.videos.quickUpload({ + name: 'password protected video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ videoPassword ] + }) + await waitJobs([ server ]) + + await checkVideoFiles({ + id: uuid, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + token: null, + videoFileToken: null, + videoPassword: null + }) + }) + + it('Should not be able to access an password video files with incorrect OAuth token, file token and password', async function () { + this.timeout(120000) + const videoPassword = 'my super password' + + const { uuid } = await server.videos.quickUpload({ + name: 'password protected video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ videoPassword ] + }) + await waitJobs([ server ]) + + await checkVideoFiles({ + id: uuid, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + token: userToken, + videoFileToken: unrelatedFileToken, + videoPassword: 'incorrectPassword' + }) + }) + + it('Should not be able to access an private video files without appropriate OAuth token and file token', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + await waitJobs([ server ]) + + await checkVideoFiles({ + id: uuid, + expectedStatus: HttpStatusCode.FORBIDDEN_403, + token: userToken, + videoFileToken: unrelatedFileToken + }) + }) + + it('Should be able to access a private video files with appropriate OAuth token or file token', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + + await waitJobs([ server ]) + + await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken }) + }) + + it('Should be able to access a password protected video files with appropriate OAuth token or file token', async function () { + this.timeout(120000) + const videoPassword = 'my super password' + + const { uuid } = await server.videos.quickUpload({ + name: 'video', + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ videoPassword ] + }) + + const videoFileToken = await server.videoToken.getVideoFileToken({ token: null, videoId: uuid, videoPassword }) + + await waitJobs([ server ]) + + await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken, videoPassword }) + }) + + it('Should reinject video file token', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) + + const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + await waitJobs([ server ]) + + { + const video = await server.videos.getWithToken({ id: uuid }) + const hls = video.streamingPlaylists[0] + const query = { videoFileToken } + const { text } = await makeRawRequest({ url: hls.playlistUrl, query, expectedStatus: HttpStatusCode.OK_200 }) + + expect(text).to.not.include(videoFileToken) + } + + { + await checkVideoFileTokenReinjection({ + server, + videoUUID: uuid, + videoFileToken, + resolutions: [ 240, 720 ], + isLive: false + }) + } + }) + + it('Should be able to access a private video of another user with an admin OAuth token or file token', async function () { + this.timeout(120000) + + const { uuid } = await server.videos.quickUpload({ name: 'video', token: userToken, privacy: VideoPrivacy.PRIVATE }) + const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + + await waitJobs([ server ]) + + await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken }) + }) + }) + + describe('Live static file path and check', function () { + let normalLiveId: string + let normalLive: LiveVideo + + let permanentLiveId: string + let permanentLive: LiveVideo + + let passwordProtectedLiveId: string + let passwordProtectedLive: LiveVideo + + const correctPassword = 'my super password' + + let unrelatedFileToken: string + + async function checkLiveFiles (options: { live: LiveVideo, liveId: string, videoPassword?: string }) { + const { live, liveId, videoPassword } = options + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + await server.live.waitUntilPublished({ videoId: liveId }) + + const video = await server.videos.getWithToken({ id: liveId }) + + const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) + + const hls = video.streamingPlaylists[0] + + for (const url of [ hls.playlistUrl, hls.segmentsSha256Url ]) { + expectStartWith(url, server.url + '/static/streaming-playlists/hls/private/') + + await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + + if (videoPassword) { + await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ + url, + headers: { 'x-peertube-video-password': 'incorrectPassword' }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } + + } + + await stopFfmpeg(ffmpegCommand) + } + + async function checkReplay (replay: VideoDetails) { + const fileToken = await server.videoToken.getVideoFileToken({ videoId: replay.uuid }) + + const hls = replay.streamingPlaylists[0] + expect(hls.files).to.not.have.lengthOf(0) + + for (const file of hls.files) { + await makeRawRequest({ url: file.fileUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: file.fileUrl, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url: file.fileUrl, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ + url: file.fileUrl, + query: { videoFileToken: unrelatedFileToken }, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + } + + for (const url of [ hls.playlistUrl, hls.segmentsSha256Url ]) { + expectStartWith(url, server.url + '/static/streaming-playlists/hls/private/') + + await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) + + await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + } + } + + before(async function () { + await server.config.enableMinimumTranscoding() + + const { uuid } = await server.videos.quickUpload({ name: 'another video' }) + unrelatedFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid }) + + await server.config.enableLive({ + allowReplay: true, + transcoding: true, + resolutions: 'min' + }) + + { + const { video, live } = await server.live.quickCreate({ + saveReplay: true, + permanentLive: false, + privacy: VideoPrivacy.PRIVATE + }) + normalLiveId = video.uuid + normalLive = live + } + + { + const { video, live } = await server.live.quickCreate({ + saveReplay: true, + permanentLive: true, + privacy: VideoPrivacy.PRIVATE + }) + permanentLiveId = video.uuid + permanentLive = live + } + + { + const { video, live } = await server.live.quickCreate({ + saveReplay: false, + permanentLive: false, + privacy: VideoPrivacy.PASSWORD_PROTECTED, + videoPasswords: [ correctPassword ] + }) + passwordProtectedLiveId = video.uuid + passwordProtectedLive = live + } + }) + + it('Should create a private normal live and have a private static path', async function () { + this.timeout(240000) + + await checkLiveFiles({ live: normalLive, liveId: normalLiveId }) + }) + + it('Should create a private permanent live and have a private static path', async function () { + this.timeout(240000) + + await checkLiveFiles({ live: permanentLive, liveId: permanentLiveId }) + }) + + it('Should create a password protected live and have a private static path', async function () { + this.timeout(240000) + + await checkLiveFiles({ live: passwordProtectedLive, liveId: passwordProtectedLiveId, videoPassword: correctPassword }) + }) + + it('Should reinject video file token on permanent live', async function () { + this.timeout(240000) + + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: permanentLive.rtmpUrl, streamKey: permanentLive.streamKey }) + await server.live.waitUntilPublished({ videoId: permanentLiveId }) + + const video = await server.videos.getWithToken({ id: permanentLiveId }) + const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) + const hls = video.streamingPlaylists[0] + + { + const query = { videoFileToken } + const { text } = await makeRawRequest({ url: hls.playlistUrl, query, expectedStatus: HttpStatusCode.OK_200 }) + + expect(text).to.not.include(videoFileToken) + } + + { + await checkVideoFileTokenReinjection({ + server, + videoUUID: permanentLiveId, + videoFileToken, + resolutions: [ 720 ], + isLive: true + }) + } + + await stopFfmpeg(ffmpegCommand) + }) + + it('Should have created a replay of the normal live with a private static path', async function () { + this.timeout(240000) + + await server.live.waitUntilReplacedByReplay({ videoId: normalLiveId }) + + const replay = await server.videos.getWithToken({ id: normalLiveId }) + await checkReplay(replay) + }) + + it('Should have created a replay of the permanent live with a private static path', async function () { + this.timeout(240000) + + await server.live.waitUntilWaiting({ videoId: permanentLiveId }) + await waitJobs([ server ]) + + const live = await server.videos.getWithToken({ id: permanentLiveId }) + const replayFromList = await findExternalSavedVideo(server, live) + const replay = await server.videos.getWithToken({ id: replayFromList.id }) + + await checkReplay(replay) + }) + }) + + describe('With static file right check disabled', function () { + let videoUUID: string + + before(async function () { + this.timeout(240000) + + await server.kill() + + await server.run({ + static_files: { + private_files_require_auth: false + } + }) + + const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.INTERNAL }) + videoUUID = uuid + + await waitJobs([ server ]) + }) + + it('Should not check auth for private static files', async function () { + const video = await server.videos.getWithToken({ id: videoUUID }) + + for (const file of getAllFiles(video)) { + await makeRawRequest({ url: file.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } + + const hls = video.streamingPlaylists[0] + await makeRawRequest({ url: hls.playlistUrl, expectedStatus: HttpStatusCode.OK_200 }) + await makeRawRequest({ url: hls.segmentsSha256Url, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/video-storyboard.ts b/packages/tests/src/api/videos/video-storyboard.ts new file mode 100644 index 000000000..7d156aa7f --- /dev/null +++ b/packages/tests/src/api/videos/video-storyboard.ts @@ -0,0 +1,213 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { readdir } from 'fs/promises' +import { basename } from 'path' +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils' +import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + PeerTubeServer, + sendRTMPStream, + setAccessTokensToServers, + setDefaultVideoChannel, + stopFfmpeg, + waitJobs +} from '@peertube/peertube-server-commands' + +async function checkStoryboard (options: { + server: PeerTubeServer + uuid: string + tilesCount?: number + minSize?: number +}) { + const { server, uuid, tilesCount, minSize = 1000 } = options + + const { storyboards } = await server.storyboard.list({ id: uuid }) + + expect(storyboards).to.have.lengthOf(1) + + const storyboard = storyboards[0] + + expect(storyboard.spriteDuration).to.equal(1) + expect(storyboard.spriteHeight).to.equal(108) + expect(storyboard.spriteWidth).to.equal(192) + expect(storyboard.storyboardPath).to.exist + + if (tilesCount) { + expect(storyboard.totalWidth).to.equal(192 * Math.min(tilesCount, 10)) + expect(storyboard.totalHeight).to.equal(108 * Math.max((tilesCount / 10), 1)) + } + + const { body } = await makeGetRequest({ url: server.url, path: storyboard.storyboardPath, expectedStatus: HttpStatusCode.OK_200 }) + expect(body.length).to.be.above(minSize) +} + +describe('Test video storyboard', function () { + let servers: PeerTubeServer[] + + let baseUUID: string + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await doubleFollow(servers[0], servers[1]) + }) + + it('Should generate a storyboard after upload without transcoding', async function () { + this.timeout(120000) + + // 5s video + const { uuid } = await servers[0].videos.quickUpload({ name: 'upload', fixture: 'video_short.webm' }) + baseUUID = uuid + await waitJobs(servers) + + for (const server of servers) { + await checkStoryboard({ server, uuid, tilesCount: 5 }) + } + }) + + it('Should generate a storyboard after upload without transcoding with a long video', async function () { + this.timeout(120000) + + // 124s video + const { uuid } = await servers[0].videos.quickUpload({ name: 'upload', fixture: 'video_very_long_10p.mp4' }) + await waitJobs(servers) + + for (const server of servers) { + await checkStoryboard({ server, uuid, tilesCount: 100 }) + } + }) + + it('Should generate a storyboard after upload with transcoding', async function () { + this.timeout(120000) + + await servers[0].config.enableMinimumTranscoding() + + // 5s video + const { uuid } = await servers[0].videos.quickUpload({ name: 'upload', fixture: 'video_short.webm' }) + await waitJobs(servers) + + for (const server of servers) { + await checkStoryboard({ server, uuid, tilesCount: 5 }) + } + }) + + it('Should generate a storyboard after an audio upload', async function () { + this.timeout(120000) + + // 6s audio + const attributes = { name: 'audio', fixture: 'sample.ogg' } + const { uuid } = await servers[0].videos.upload({ attributes, mode: 'legacy' }) + await waitJobs(servers) + + for (const server of servers) { + try { + await checkStoryboard({ server, uuid, tilesCount: 6, minSize: 250 }) + } catch { // FIXME: to remove after ffmpeg CI upgrade, ffmpeg CI version (4.3) generates a 7.6s length video + await checkStoryboard({ server, uuid, tilesCount: 8, minSize: 250 }) + } + } + }) + + it('Should generate a storyboard after HTTP import', async function () { + this.timeout(120000) + + if (areHttpImportTestsDisabled()) return + + // 3s video + const { video } = await servers[0].imports.importVideo({ + attributes: { + targetUrl: FIXTURE_URLS.goodVideo, + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + }) + await waitJobs(servers) + + for (const server of servers) { + await checkStoryboard({ server, uuid: video.uuid, tilesCount: 3 }) + } + }) + + it('Should generate a storyboard after torrent import', async function () { + this.timeout(120000) + + if (areHttpImportTestsDisabled()) return + + // 10s video + const { video } = await servers[0].imports.importVideo({ + attributes: { + magnetUri: FIXTURE_URLS.magnet, + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC + } + }) + await waitJobs(servers) + + for (const server of servers) { + await checkStoryboard({ server, uuid: video.uuid, tilesCount: 10 }) + } + }) + + it('Should generate a storyboard after a live', async function () { + this.timeout(240000) + + await servers[0].config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' }) + + const { live, video } = await servers[0].live.quickCreate({ + saveReplay: true, + permanentLive: false, + privacy: VideoPrivacy.PUBLIC + }) + + const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) + await servers[0].live.waitUntilPublished({ videoId: video.id }) + + await stopFfmpeg(ffmpegCommand) + + await servers[0].live.waitUntilReplacedByReplay({ videoId: video.id }) + await waitJobs(servers) + + for (const server of servers) { + await checkStoryboard({ server, uuid: video.uuid }) + } + }) + + it('Should cleanup storyboards on video deletion', async function () { + this.timeout(60000) + + const { storyboards } = await servers[0].storyboard.list({ id: baseUUID }) + const storyboardName = basename(storyboards[0].storyboardPath) + + const listFiles = () => { + const storyboardPath = servers[0].getDirectoryPath('storyboards') + return readdir(storyboardPath) + } + + { + const storyboads = await listFiles() + expect(storyboads).to.include(storyboardName) + } + + await servers[0].videos.remove({ id: baseUUID }) + await waitJobs(servers) + + { + const storyboads = await listFiles() + expect(storyboads).to.not.include(storyboardName) + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/videos-common-filters.ts b/packages/tests/src/api/videos/videos-common-filters.ts new file mode 100644 index 000000000..9e75bd6ca --- /dev/null +++ b/packages/tests/src/api/videos/videos-common-filters.ts @@ -0,0 +1,499 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { pick } from '@peertube/peertube-core-utils' +import { + HttpStatusCode, + HttpStatusCodeType, + UserRole, + Video, + VideoDetails, + VideoInclude, + VideoIncludeType, + VideoPrivacy, + VideoPrivacyType +} from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeGetRequest, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test videos filter', function () { + let servers: PeerTubeServer[] + let paths: string[] + let remotePaths: string[] + + const subscriptionVideosPath = '/api/v1/users/me/subscriptions/videos' + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(240000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultAccountAvatar(servers) + + await servers[1].config.enableMinimumTranscoding() + + for (const server of servers) { + const moderator = { username: 'moderator', password: 'my super password' } + await server.users.create({ username: moderator.username, password: moderator.password, role: UserRole.MODERATOR }) + server['moderatorAccessToken'] = await server.login.getAccessToken(moderator) + + await server.videos.upload({ attributes: { name: 'public ' + server.serverNumber } }) + + { + const attributes = { name: 'unlisted ' + server.serverNumber, privacy: VideoPrivacy.UNLISTED } + await server.videos.upload({ attributes }) + } + + { + const attributes = { name: 'private ' + server.serverNumber, privacy: VideoPrivacy.PRIVATE } + await server.videos.upload({ attributes }) + } + + // Subscribing to itself + await server.subscriptions.add({ targetUri: 'root_channel@' + server.host }) + } + + await doubleFollow(servers[0], servers[1]) + + paths = [ + `/api/v1/video-channels/root_channel/videos`, + `/api/v1/accounts/root/videos`, + '/api/v1/videos', + '/api/v1/search/videos', + subscriptionVideosPath + ] + + remotePaths = [ + `/api/v1/video-channels/root_channel@${servers[1].host}/videos`, + `/api/v1/accounts/root@${servers[1].host}/videos`, + '/api/v1/videos', + '/api/v1/search/videos' + ] + }) + + describe('Check videos filters', function () { + + async function listVideos (options: { + server: PeerTubeServer + path: string + isLocal?: boolean + hasWebVideoFiles?: boolean + hasHLSFiles?: boolean + include?: VideoIncludeType + privacyOneOf?: VideoPrivacyType[] + category?: number + tagsAllOf?: string[] + token?: string + expectedStatus?: HttpStatusCodeType + excludeAlreadyWatched?: boolean + }) { + const res = await makeGetRequest({ + url: options.server.url, + path: options.path, + token: options.token ?? options.server.accessToken, + query: { + ...pick(options, [ + 'isLocal', + 'include', + 'category', + 'tagsAllOf', + 'hasWebVideoFiles', + 'hasHLSFiles', + 'privacyOneOf', + 'excludeAlreadyWatched' + ]), + + sort: 'createdAt' + }, + expectedStatus: options.expectedStatus ?? HttpStatusCode.OK_200 + }) + + return res.body.data as Video[] + } + + async function getVideosNames ( + options: { + server: PeerTubeServer + isLocal?: boolean + include?: VideoIncludeType + privacyOneOf?: VideoPrivacyType[] + token?: string + expectedStatus?: HttpStatusCodeType + skipSubscription?: boolean + excludeAlreadyWatched?: boolean + } + ) { + const { skipSubscription = false } = options + const videosResults: string[][] = [] + + for (const path of paths) { + if (skipSubscription && path === subscriptionVideosPath) continue + + const videos = await listVideos({ ...options, path }) + + videosResults.push(videos.map(v => v.name)) + } + + return videosResults + } + + it('Should display local videos', async function () { + for (const server of servers) { + const namesResults = await getVideosNames({ server, isLocal: true }) + + for (const names of namesResults) { + expect(names).to.have.lengthOf(1) + expect(names[0]).to.equal('public ' + server.serverNumber) + } + } + }) + + it('Should display local videos with hidden privacy by the admin or the moderator', async function () { + for (const server of servers) { + for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { + + const namesResults = await getVideosNames( + { + server, + token, + isLocal: true, + privacyOneOf: [ VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC, VideoPrivacy.PRIVATE ], + skipSubscription: true + } + ) + + for (const names of namesResults) { + expect(names).to.have.lengthOf(3) + + expect(names[0]).to.equal('public ' + server.serverNumber) + expect(names[1]).to.equal('unlisted ' + server.serverNumber) + expect(names[2]).to.equal('private ' + server.serverNumber) + } + } + } + }) + + it('Should display all videos by the admin or the moderator', async function () { + for (const server of servers) { + for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { + + const [ channelVideos, accountVideos, videos, searchVideos ] = await getVideosNames({ + server, + token, + privacyOneOf: [ VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC, VideoPrivacy.PRIVATE ] + }) + + expect(channelVideos).to.have.lengthOf(3) + expect(accountVideos).to.have.lengthOf(3) + + expect(videos).to.have.lengthOf(5) + expect(searchVideos).to.have.lengthOf(5) + } + } + }) + + it('Should display only remote videos', async function () { + this.timeout(120000) + + await servers[1].videos.upload({ attributes: { name: 'remote video' } }) + + await waitJobs(servers) + + const finder = (videos: Video[]) => videos.find(v => v.name === 'remote video') + + for (const path of remotePaths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.exist + } + + { + const videos = await listVideos({ server: servers[0], path, isLocal: false }) + const video = finder(videos) + expect(video).to.exist + } + + { + const videos = await listVideos({ server: servers[0], path, isLocal: true }) + const video = finder(videos) + expect(video).to.not.exist + } + } + }) + + it('Should include not published videos', async function () { + await servers[0].config.enableLive({ allowReplay: false, transcoding: false }) + await servers[0].live.create({ fields: { name: 'live video', channelId: servers[0].store.channel.id, privacy: VideoPrivacy.PUBLIC } }) + + const finder = (videos: Video[]) => videos.find(v => v.name === 'live video') + + for (const path of paths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.not.exist + expect(videos[0].state).to.not.exist + expect(videos[0].waitTranscoding).to.not.exist + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.NOT_PUBLISHED_STATE }) + const video = finder(videos) + expect(video).to.exist + expect(video.state).to.exist + } + } + }) + + it('Should include blacklisted videos', async function () { + const { id } = await servers[0].videos.upload({ attributes: { name: 'blacklisted' } }) + + await servers[0].blacklist.add({ videoId: id }) + + const finder = (videos: Video[]) => videos.find(v => v.name === 'blacklisted') + + for (const path of paths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.not.exist + expect(videos[0].blacklisted).to.not.exist + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.BLACKLISTED }) + const video = finder(videos) + expect(video).to.exist + expect(video.blacklisted).to.be.true + } + } + }) + + it('Should include videos from muted account', async function () { + const finder = (videos: Video[]) => videos.find(v => v.name === 'remote video') + + await servers[0].blocklist.addToServerBlocklist({ account: 'root@' + servers[1].host }) + + for (const path of remotePaths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.not.exist + + // Some paths won't have videos + if (videos[0]) { + expect(videos[0].blockedOwner).to.not.exist + expect(videos[0].blockedServer).to.not.exist + } + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.BLOCKED_OWNER }) + + const video = finder(videos) + expect(video).to.exist + expect(video.blockedServer).to.be.false + expect(video.blockedOwner).to.be.true + } + } + + await servers[0].blocklist.removeFromServerBlocklist({ account: 'root@' + servers[1].host }) + }) + + it('Should include videos from muted server', async function () { + const finder = (videos: Video[]) => videos.find(v => v.name === 'remote video') + + await servers[0].blocklist.addToServerBlocklist({ server: servers[1].host }) + + for (const path of remotePaths) { + { + const videos = await listVideos({ server: servers[0], path }) + const video = finder(videos) + expect(video).to.not.exist + + // Some paths won't have videos + if (videos[0]) { + expect(videos[0].blockedOwner).to.not.exist + expect(videos[0].blockedServer).to.not.exist + } + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.BLOCKED_OWNER }) + const video = finder(videos) + expect(video).to.exist + expect(video.blockedServer).to.be.true + expect(video.blockedOwner).to.be.false + } + } + + await servers[0].blocklist.removeFromServerBlocklist({ server: servers[1].host }) + }) + + it('Should include video files', async function () { + for (const path of paths) { + { + const videos = await listVideos({ server: servers[0], path }) + + for (const video of videos) { + const videoWithFiles = video as VideoDetails + + expect(videoWithFiles.files).to.not.exist + expect(videoWithFiles.streamingPlaylists).to.not.exist + } + } + + { + const videos = await listVideos({ server: servers[0], path, include: VideoInclude.FILES }) + + for (const video of videos) { + const videoWithFiles = video as VideoDetails + + expect(videoWithFiles.files).to.exist + expect(videoWithFiles.files).to.have.length.at.least(1) + } + } + } + }) + + it('Should filter by tags and category', async function () { + await servers[0].videos.upload({ attributes: { name: 'tag filter', tags: [ 'tag1', 'tag2' ] } }) + await servers[0].videos.upload({ attributes: { name: 'tag filter with category', tags: [ 'tag3' ], category: 4 } }) + + for (const path of paths) { + { + const videos = await listVideos({ server: servers[0], path, tagsAllOf: [ 'tag1', 'tag2' ] }) + expect(videos).to.have.lengthOf(1) + expect(videos[0].name).to.equal('tag filter') + } + + { + const videos = await listVideos({ server: servers[0], path, tagsAllOf: [ 'tag1', 'tag3' ] }) + expect(videos).to.have.lengthOf(0) + } + + { + const { data, total } = await servers[0].videos.list({ tagsAllOf: [ 'tag3' ], categoryOneOf: [ 4 ] }) + expect(total).to.equal(1) + expect(data[0].name).to.equal('tag filter with category') + } + + { + const { total } = await servers[0].videos.list({ tagsAllOf: [ 'tag4' ], categoryOneOf: [ 4 ] }) + expect(total).to.equal(0) + } + } + }) + + it('Should filter by HLS or Web Video files', async function () { + this.timeout(360000) + + const finderFactory = (name: string) => (videos: Video[]) => videos.some(v => v.name === name) + + await servers[0].config.enableTranscoding({ hls: false, webVideo: true }) + await servers[0].videos.upload({ attributes: { name: 'web video' } }) + const hasWebVideo = finderFactory('web video') + + await waitJobs(servers) + + await servers[0].config.enableTranscoding({ hls: true, webVideo: false }) + await servers[0].videos.upload({ attributes: { name: 'hls video' } }) + const hasHLS = finderFactory('hls video') + + await waitJobs(servers) + + await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) + await servers[0].videos.upload({ attributes: { name: 'hls and web video' } }) + const hasBoth = finderFactory('hls and web video') + + await waitJobs(servers) + + for (const path of paths) { + { + const videos = await listVideos({ server: servers[0], path, hasWebVideoFiles: true }) + + expect(hasWebVideo(videos)).to.be.true + expect(hasHLS(videos)).to.be.false + expect(hasBoth(videos)).to.be.true + } + + { + const videos = await listVideos({ server: servers[0], path, hasWebVideoFiles: false }) + + expect(hasWebVideo(videos)).to.be.false + expect(hasHLS(videos)).to.be.true + expect(hasBoth(videos)).to.be.false + } + + { + const videos = await listVideos({ server: servers[0], path, hasHLSFiles: true }) + + expect(hasWebVideo(videos)).to.be.false + expect(hasHLS(videos)).to.be.true + expect(hasBoth(videos)).to.be.true + } + + { + const videos = await listVideos({ server: servers[0], path, hasHLSFiles: false }) + + expect(hasWebVideo(videos)).to.be.true + expect(hasHLS(videos)).to.be.false + expect(hasBoth(videos)).to.be.false + } + + { + const videos = await listVideos({ server: servers[0], path, hasHLSFiles: false, hasWebVideoFiles: false }) + + expect(hasWebVideo(videos)).to.be.false + expect(hasHLS(videos)).to.be.false + expect(hasBoth(videos)).to.be.false + } + + { + const videos = await listVideos({ server: servers[0], path, hasHLSFiles: true, hasWebVideoFiles: true }) + + expect(hasWebVideo(videos)).to.be.false + expect(hasHLS(videos)).to.be.false + expect(hasBoth(videos)).to.be.true + } + } + }) + + it('Should filter already watched videos by the user', async function () { + const { id } = await servers[0].videos.upload({ attributes: { name: 'video for history' } }) + + for (const path of paths) { + const videos = await listVideos({ server: servers[0], path, isLocal: true, excludeAlreadyWatched: true }) + const foundVideo = videos.find(video => video.id === id) + + expect(foundVideo).to.not.be.undefined + } + await servers[0].views.view({ id, currentTime: 1, token: servers[0].accessToken }) + + for (const path of paths) { + const videos = await listVideos({ server: servers[0], path, excludeAlreadyWatched: true }) + const foundVideo = videos.find(video => video.id === id) + + expect(foundVideo).to.be.undefined + } + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/videos-history.ts b/packages/tests/src/api/videos/videos-history.ts new file mode 100644 index 000000000..75c0fcebd --- /dev/null +++ b/packages/tests/src/api/videos/videos-history.ts @@ -0,0 +1,230 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { Video } from '@peertube/peertube-models' +import { + cleanupTests, + createSingleServer, + killallServers, + PeerTubeServer, + setAccessTokensToServers +} from '@peertube/peertube-server-commands' + +describe('Test videos history', function () { + let server: PeerTubeServer = null + let video1Id: number + let video1UUID: string + let video2UUID: string + let video3UUID: string + let video3WatchedDate: Date + let userAccessToken: string + + before(async function () { + this.timeout(120000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + + // 10 seconds long + const fixture = 'video_short1.webm' + + { + const { id, uuid } = await server.videos.upload({ attributes: { name: 'video 1', fixture } }) + video1UUID = uuid + video1Id = id + } + + { + const { uuid } = await server.videos.upload({ attributes: { name: 'video 2', fixture } }) + video2UUID = uuid + } + + { + const { uuid } = await server.videos.upload({ attributes: { name: 'video 3', fixture } }) + video3UUID = uuid + } + + userAccessToken = await server.users.generateUserAndToken('user_1') + }) + + it('Should get videos, without watching history', async function () { + const { data } = await server.videos.listWithToken() + + for (const video of data) { + const videoDetails = await server.videos.getWithToken({ id: video.id }) + + expect(video.userHistory).to.be.undefined + expect(videoDetails.userHistory).to.be.undefined + } + }) + + it('Should watch the first and second video', async function () { + await server.views.view({ id: video2UUID, token: server.accessToken, currentTime: 8 }) + await server.views.view({ id: video1UUID, token: server.accessToken, currentTime: 3 }) + }) + + it('Should return the correct history when listing, searching and getting videos', async function () { + const videosOfVideos: Video[][] = [] + + { + const { data } = await server.videos.listWithToken() + videosOfVideos.push(data) + } + + { + const body = await server.search.searchVideos({ token: server.accessToken, search: 'video' }) + videosOfVideos.push(body.data) + } + + for (const videos of videosOfVideos) { + const video1 = videos.find(v => v.uuid === video1UUID) + const video2 = videos.find(v => v.uuid === video2UUID) + const video3 = videos.find(v => v.uuid === video3UUID) + + expect(video1.userHistory).to.not.be.undefined + expect(video1.userHistory.currentTime).to.equal(3) + + expect(video2.userHistory).to.not.be.undefined + expect(video2.userHistory.currentTime).to.equal(8) + + expect(video3.userHistory).to.be.undefined + } + + { + const videoDetails = await server.videos.getWithToken({ id: video1UUID }) + + expect(videoDetails.userHistory).to.not.be.undefined + expect(videoDetails.userHistory.currentTime).to.equal(3) + } + + { + const videoDetails = await server.videos.getWithToken({ id: video2UUID }) + + expect(videoDetails.userHistory).to.not.be.undefined + expect(videoDetails.userHistory.currentTime).to.equal(8) + } + + { + const videoDetails = await server.videos.getWithToken({ id: video3UUID }) + + expect(videoDetails.userHistory).to.be.undefined + } + }) + + it('Should have these videos when listing my history', async function () { + video3WatchedDate = new Date() + await server.views.view({ id: video3UUID, token: server.accessToken, currentTime: 2 }) + + const body = await server.history.list() + + expect(body.total).to.equal(3) + + const videos = body.data + expect(videos[0].name).to.equal('video 3') + expect(videos[1].name).to.equal('video 1') + expect(videos[2].name).to.equal('video 2') + }) + + it('Should not have videos history on another user', async function () { + const body = await server.history.list({ token: userAccessToken }) + + expect(body.total).to.equal(0) + expect(body.data).to.have.lengthOf(0) + }) + + it('Should be able to search through videos in my history', async function () { + const body = await server.history.list({ search: '2' }) + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos[0].name).to.equal('video 2') + }) + + it('Should clear my history', async function () { + await server.history.removeAll({ beforeDate: video3WatchedDate.toISOString() }) + }) + + it('Should have my history cleared', async function () { + const body = await server.history.list() + expect(body.total).to.equal(1) + + const videos = body.data + expect(videos[0].name).to.equal('video 3') + }) + + it('Should disable videos history', async function () { + await server.users.updateMe({ + videosHistoryEnabled: false + }) + + await server.views.view({ id: video2UUID, token: server.accessToken, currentTime: 8 }) + + const { data } = await server.history.list() + expect(data[0].name).to.not.equal('video 2') + }) + + it('Should re-enable videos history', async function () { + await server.users.updateMe({ + videosHistoryEnabled: true + }) + + await server.views.view({ id: video2UUID, token: server.accessToken, currentTime: 8 }) + + const { data } = await server.history.list() + expect(data[0].name).to.equal('video 2') + }) + + it('Should not clean old history', async function () { + this.timeout(50000) + + await killallServers([ server ]) + + await server.run({ history: { videos: { max_age: '10 days' } } }) + + await wait(6000) + + // Should still have history + + const body = await server.history.list() + expect(body.total).to.equal(2) + }) + + it('Should clean old history', async function () { + this.timeout(50000) + + await killallServers([ server ]) + + await server.run({ history: { videos: { max_age: '5 seconds' } } }) + + await wait(6000) + + const body = await server.history.list() + expect(body.total).to.equal(0) + }) + + it('Should delete a specific history element', async function () { + { + await server.views.view({ id: video1UUID, token: server.accessToken, currentTime: 4 }) + await server.views.view({ id: video2UUID, token: server.accessToken, currentTime: 8 }) + } + + { + const body = await server.history.list() + expect(body.total).to.equal(2) + } + + { + await server.history.removeElement({ videoId: video1Id }) + + const body = await server.history.list() + expect(body.total).to.equal(1) + expect(body.data[0].uuid).to.equal(video2UUID) + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/videos-overview.ts b/packages/tests/src/api/videos/videos-overview.ts new file mode 100644 index 000000000..7d74d6db2 --- /dev/null +++ b/packages/tests/src/api/videos/videos-overview.ts @@ -0,0 +1,129 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@peertube/peertube-core-utils' +import { VideosOverview } from '@peertube/peertube-models' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' + +describe('Test a videos overview', function () { + let server: PeerTubeServer = null + + function testOverviewCount (overview: VideosOverview, expected: number) { + expect(overview.tags).to.have.lengthOf(expected) + expect(overview.categories).to.have.lengthOf(expected) + expect(overview.channels).to.have.lengthOf(expected) + } + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + }) + + it('Should send empty overview', async function () { + const body = await server.overviews.getVideos({ page: 1 }) + + testOverviewCount(body, 0) + }) + + it('Should upload 5 videos in a specific category, tag and channel but not include them in overview', async function () { + this.timeout(60000) + + await wait(3000) + + await server.videos.upload({ + attributes: { + name: 'video 0', + category: 3, + tags: [ 'coucou1', 'coucou2' ] + } + }) + + const body = await server.overviews.getVideos({ page: 1 }) + + testOverviewCount(body, 0) + }) + + it('Should upload another video and include all videos in the overview', async function () { + this.timeout(120000) + + { + for (let i = 1; i < 6; i++) { + await server.videos.upload({ + attributes: { + name: 'video ' + i, + category: 3, + tags: [ 'coucou1', 'coucou2' ] + } + }) + } + + await wait(3000) + } + + { + const body = await server.overviews.getVideos({ page: 1 }) + + testOverviewCount(body, 1) + } + + { + const overview = await server.overviews.getVideos({ page: 2 }) + + expect(overview.tags).to.have.lengthOf(1) + expect(overview.categories).to.have.lengthOf(0) + expect(overview.channels).to.have.lengthOf(0) + } + }) + + it('Should have the correct overview', async function () { + const overview1 = await server.overviews.getVideos({ page: 1 }) + const overview2 = await server.overviews.getVideos({ page: 2 }) + + for (const arr of [ overview1.tags, overview1.categories, overview1.channels, overview2.tags ]) { + expect(arr).to.have.lengthOf(1) + + const obj = arr[0] + + expect(obj.videos).to.have.lengthOf(6) + expect(obj.videos[0].name).to.equal('video 5') + expect(obj.videos[1].name).to.equal('video 4') + expect(obj.videos[2].name).to.equal('video 3') + expect(obj.videos[3].name).to.equal('video 2') + expect(obj.videos[4].name).to.equal('video 1') + expect(obj.videos[5].name).to.equal('video 0') + } + + const tags = [ overview1.tags[0].tag, overview2.tags[0].tag ] + expect(tags.find(t => t === 'coucou1')).to.not.be.undefined + expect(tags.find(t => t === 'coucou2')).to.not.be.undefined + + expect(overview1.categories[0].category.id).to.equal(3) + + expect(overview1.channels[0].channel.name).to.equal('root_channel') + }) + + it('Should hide muted accounts', async function () { + const token = await server.users.generateUserAndToken('choco') + + await server.blocklist.addToMyBlocklist({ token, account: 'root@' + server.host }) + + { + const body = await server.overviews.getVideos({ page: 1 }) + + testOverviewCount(body, 1) + } + + { + const body = await server.overviews.getVideos({ page: 1, token }) + + testOverviewCount(body, 0) + } + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) -- cgit v1.2.3