diff --git a/CHANGELOG.md b/CHANGELOG.md index b9535fa94..d119d3d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## v6.0.3 + +### IMPORTANT NOTES + + * If you upgrade from PeerTube **< v6.0.0**, please follow v6.0.0 IMPORTANT NOTES + * If you upgrade from PeerTube **v6.0.0**, please follow v6.0.1 IMPORTANT NOTES + +### SECURITY + + * Prevent nginx from serving private/internal/password protected HLS video static files + * You must update your nginx configuration like in [this commit](https://github.com/Chocobozzz/PeerTube/commit/12ea8f0dd11e3fb5fbb8955f5b7d52f27332d619#diff-be9f96b9b1de67284047e610821493f9a5bec86bfcdf81a7d8d6e7904474c186) (line `202` replace `location ~ ^(/static/(webseed|web-videos|streaming-playlists)/private/)|^/download {` by `location ~ ^(/static/(webseed|web-videos|streaming-playlists/hls)/private/)|^/download {`) + +### Bug fixes + + * Fix HTML meta tags with attributes that contain quotes + * Fix time parsing resulting in broken video start time in some cases + * Fix WebTorrent video import crash + * Reload *Discover* page on logout + * Fix privacy error when updating a live, even if the privacy has not changed + * Fix invalid remote live state change notification that causes the player to reload + * Don't apply big play button skin to settings menu + * Fix downloading video files from object storage with some video names (that include emojis, quotes etc) + * Fix thumbnail generation when ffmpeg cannot seek the input + * Fix theme colors on stats page + * Fix input mask (used for chapters, playlist timecodes...) with 10h+ videos + * Fix chapter *position* width consistency + * Fix player ratio with audio only videos + * Also update video playlist URLs when using `update-host` script + * Fix upload/import/update of videos that contain multiple chapters with the same timecode + + ## v6.0.2 ### IMPORTANT NOTES diff --git a/client/package.json b/client/package.json index 8bdddf804..2d695321b 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "peertube-client", - "version": "6.0.2", + "version": "6.0.3", "private": true, "license": "AGPL-3.0", "author": { diff --git a/client/src/app/+stats/video/video-stats.component.ts b/client/src/app/+stats/video/video-stats.component.ts index 921718247..e350738da 100644 --- a/client/src/app/+stats/video/video-stats.component.ts +++ b/client/src/app/+stats/video/video-stats.component.ts @@ -1,4 +1,4 @@ -import { ChartConfiguration, ChartData, ChartOptions, PluginOptionsByType, Scale, TooltipItem } from 'chart.js' +import { ChartConfiguration, ChartData, ChartOptions, PluginOptionsByType, Scale, TooltipItem, defaults as ChartJSDefaults } from 'chart.js' import zoomPlugin from 'chartjs-plugin-zoom' import { Observable, of } from 'rxjs' import { SelectOptionsItem } from 'src/types' @@ -35,6 +35,10 @@ type ChartBuilderResult = { type Card = { label: string, value: string | number, moreInfo?: string, help?: string } +ChartJSDefaults.backgroundColor = getComputedStyle(document.body).getPropertyValue('--mainBackgroundColor') +ChartJSDefaults.borderColor = getComputedStyle(document.body).getPropertyValue('--greySecondaryBackgroundColor') +ChartJSDefaults.color = getComputedStyle(document.body).getPropertyValue('--mainForegroundColor') + @Component({ templateUrl: './video-stats.component.html', styleUrls: [ './video-stats.component.scss' ], diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss index a81d62dd1..944d21caf 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss @@ -130,6 +130,7 @@ p-calendar { .position { height: 31px; + min-width: 16px; display: flex; align-items: center; } diff --git a/client/src/app/+videos/video-list/overview/video-overview.component.ts b/client/src/app/+videos/video-list/overview/video-overview.component.ts index b32e8f381..c7720ad5c 100644 --- a/client/src/app/+videos/video-list/overview/video-overview.component.ts +++ b/client/src/app/+videos/video-list/overview/video-overview.component.ts @@ -1,5 +1,5 @@ -import { Subject } from 'rxjs' -import { Component, OnInit } from '@angular/core' +import { Subject, Subscription, switchMap } from 'rxjs' +import { Component, OnDestroy, OnInit } from '@angular/core' import { Notifier, ScreenService, User, UserService } from '@app/core' import { Video } from '@app/shared/shared-main' import { OverviewService } from './overview.service' @@ -10,7 +10,7 @@ import { VideosOverview } from './videos-overview.model' templateUrl: './video-overview.component.html', styleUrls: [ './video-overview.component.scss' ] }) -export class VideoOverviewComponent implements OnInit { +export class VideoOverviewComponent implements OnInit, OnDestroy { onDataSubject = new Subject() overviews: VideosOverview[] = [] @@ -24,6 +24,8 @@ export class VideoOverviewComponent implements OnInit { private lastWasEmpty = false private isLoading = false + private userSub: Subscription + constructor ( private notifier: Notifier, private userService: UserService, @@ -37,8 +39,18 @@ export class VideoOverviewComponent implements OnInit { this.userService.getAnonymousOrLoggedUser() .subscribe(user => this.userMiniature = user) - this.userService.listenAnonymousUpdate() - .subscribe(user => this.userMiniature = user) + this.userSub = this.userService.listenAnonymousUpdate() + .pipe(switchMap(() => this.userService.getAnonymousOrLoggedUser())) + .subscribe(user => { + this.userMiniature = user + + this.overviews = [] + this.loadMoreResults() + }) + } + + ngOnDestroy () { + if (this.userSub) this.userSub.unsubscribe() } buildVideoChannelBy (object: { videos: Video[] }) { diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.html b/client/src/app/shared/shared-forms/timestamp-input.component.html index c89a7b019..cea73852d 100644 --- a/client/src/app/shared/shared-forms/timestamp-input.component.html +++ b/client/src/app/shared/shared-forms/timestamp-input.component.html @@ -1,5 +1,5 @@ diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.scss b/client/src/app/shared/shared-forms/timestamp-input.component.scss index df19240b4..5b5a6a5f6 100644 --- a/client/src/app/shared/shared-forms/timestamp-input.component.scss +++ b/client/src/app/shared/shared-forms/timestamp-input.component.scss @@ -26,6 +26,8 @@ p-inputmask { &:not(.border-disabled) { ::ng-deep input { @include peertube-input-text(80px); + + padding: 3px 10px; } } } diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.ts b/client/src/app/shared/shared-forms/timestamp-input.component.ts index 280491852..d3ae91875 100644 --- a/client/src/app/shared/shared-forms/timestamp-input.component.ts +++ b/client/src/app/shared/shared-forms/timestamp-input.component.ts @@ -36,7 +36,8 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit { writeValue (timestamp: number) { this.timestamp = timestamp - this.timestampString = secondsToTime(this.timestamp, true, ':') + this.timestampString = secondsToTime({ seconds: this.timestamp, fullFormat: true, symbol: ':' }) + console.log(this.timestampString) } registerOnChange (fn: (_: any) => void) { diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts index 16a1e51ca..d03e11e34 100644 --- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts +++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts @@ -144,8 +144,8 @@ export class VideoPlaylistElementMiniatureComponent implements OnInit { const start = playlistElement.startTimestamp const stop = playlistElement.stopTimestamp - const startFormatted = secondsToTime(start, true, ':') - const stopFormatted = secondsToTime(stop, true, ':') + const startFormatted = secondsToTime({ seconds: start, fullFormat: true, symbol: ':' }) + const stopFormatted = secondsToTime({ seconds: stop, fullFormat: true, symbol: ':' }) if (start === null && stop === null) return '' diff --git a/client/src/assets/player/shared/peertube/peertube-plugin.ts b/client/src/assets/player/shared/peertube/peertube-plugin.ts index 30a2d8538..80b767024 100644 --- a/client/src/assets/player/shared/peertube/peertube-plugin.ts +++ b/client/src/assets/player/shared/peertube/peertube-plugin.ts @@ -38,6 +38,8 @@ class PeerTubePlugin extends Plugin { private errorModal: videojs.ModalDialog + private hasInitialSeek = false + private videoViewOnPlayHandler: (...args: any[]) => void private videoViewOnSeekedHandler: (...args: any[]) => void private videoViewOnEndedHandler: (...args: any[]) => void @@ -227,7 +229,7 @@ class PeerTubePlugin extends Plugin { // In portrait screen mode, we allow player with bigger height size than width const portraitMode = getComputedStyle(el).getPropertyValue(this.options.autoPlayerRatio.cssPlayerPortraitModeVariable) === '1' - const currentRatio = !portraitMode && data.ratio < 1 + const currentRatio = isNaN(data.ratio) || (!portraitMode && data.ratio < 1) ? defaultRatio : data.ratio @@ -242,6 +244,7 @@ class PeerTubePlugin extends Plugin { let lastCurrentTime = startTime let lastViewEvent: VideoViewEvent + let ended = false // player.ended() is too "slow", so handle ended manually if (this.videoViewInterval) clearInterval(this.videoViewInterval) if (this.videoViewOnPlayHandler) this.player.off('play', this.videoViewOnPlayHandler) @@ -249,22 +252,38 @@ class PeerTubePlugin extends Plugin { if (this.videoViewOnEndedHandler) this.player.off('ended', this.videoViewOnEndedHandler) this.videoViewOnPlayHandler = () => { + debugLogger('Notify user is watching on play: ' + startTime) + this.notifyUserIsWatching(startTime, lastViewEvent) } this.videoViewOnSeekedHandler = () => { + // Bypass the first initial seek + if (this.hasInitialSeek) { + this.hasInitialSeek = false + return + } + const diff = Math.floor(this.player.currentTime()) - lastCurrentTime // Don't take into account small forwards if (diff > 0 && diff < 3) return + debugLogger('Detected seek event for user watching') + lastViewEvent = 'seek' } this.videoViewOnEndedHandler = () => { + ended = true + + if (this.options.isLive()) return + const currentTime = Math.floor(this.player.duration()) lastCurrentTime = currentTime + debugLogger('Notify user is watching on end: ' + currentTime) + this.notifyUserIsWatching(currentTime, lastViewEvent) lastViewEvent = undefined @@ -275,11 +294,15 @@ class PeerTubePlugin extends Plugin { this.player.one('ended', this.videoViewOnEndedHandler) this.videoViewInterval = setInterval(() => { + if (ended) return + const currentTime = Math.floor(this.player.currentTime()) // No need to update if (currentTime === lastCurrentTime) return + debugLogger('Notify user is watching: ' + currentTime) + lastCurrentTime = currentTime this.notifyUserIsWatching(currentTime, lastViewEvent) @@ -418,6 +441,7 @@ class PeerTubePlugin extends Plugin { if (startTime !== null && startTime !== undefined) { debugLogger('Start the video at ' + startTime) + this.hasInitialSeek = true this.player.currentTime(timeToInt(startTime)) } diff --git a/client/src/sass/bootstrap.scss b/client/src/sass/bootstrap.scss index 435c780c1..e2b22ee0f 100644 --- a/client/src/sass/bootstrap.scss +++ b/client/src/sass/bootstrap.scss @@ -33,6 +33,8 @@ body { --bs-border-color-translucent: #{pvar(--inputBorderColor)}; + + --bs-body-color: #{pvar(--mainForegroundColor)}; } .accordion { diff --git a/client/src/sass/player/peertube-skin.scss b/client/src/sass/player/peertube-skin.scss index 09466b5ad..ed3a6ac25 100644 --- a/client/src/sass/player/peertube-skin.scss +++ b/client/src/sass/player/peertube-skin.scss @@ -100,12 +100,15 @@ body { } } - .vjs-control-bar, - .vjs-big-play-button, - .vjs-settings-dialog { + .vjs-big-play-button { background-color: pvar(--embedBigPlayBackgroundColor); } + .vjs-settings-dialog, + .vjs-control-bar { + background-color: $primary-background-color; + } + .vjs-poster { outline: 0; background-size: cover; diff --git a/package.json b/package.json index 4900edbec..723abbaf8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "peertube", "description": "PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.", - "version": "6.0.2", + "version": "6.0.3", "private": true, "licence": "AGPL-3.0", "engines": { diff --git a/packages/core-utils/src/common/date.ts b/packages/core-utils/src/common/date.ts index 9f106945a..f3f0d4c65 100644 --- a/packages/core-utils/src/common/date.ts +++ b/packages/core-utils/src/common/date.ts @@ -52,13 +52,13 @@ function timeToInt (time: number | string) { if (typeof time === 'number') return time // Try with 00h00m00s format first - const reg = new RegExp(`^(\\d+h)?(\\d+m)?(\\d+)s?$`) + const reg = new RegExp(`^((?\\d+)h)?((?\\d+)m)?((?\\d+)s?)?$`) const matches = time.match(reg) if (matches) { - const hours = parseInt(matches[1] || '0', 10) - const minutes = parseInt(matches[2] || '0', 10) - const seconds = parseInt(matches[3] || '0', 10) + const hours = parseInt(matches.groups['hours'] || '0', 10) + const minutes = parseInt(matches.groups['minutes'] || '0', 10) + const seconds = parseInt(matches.groups['seconds'] || '0', 10) return hours * 3600 + minutes * 60 + seconds } @@ -83,29 +83,46 @@ function timeToInt (time: number | string) { return result } -function secondsToTime (seconds: number, full = false, symbol?: string) { +function secondsToTime (options: { + seconds: number + fullFormat?: boolean // default false + symbol?: string +} | number) { + let seconds: number + let fullFormat = false + let symbol: string + + if (typeof options === 'number') { + seconds = options + } else { + seconds = options.seconds + fullFormat = options.fullFormat ?? false + symbol = options.symbol + } + let time = '' - if (seconds === 0 && !full) return '0s' + if (seconds === 0 && !fullFormat) return '0s' const hourSymbol = (symbol || 'h') const minuteSymbol = (symbol || 'm') - const secondsSymbol = full ? '' : 's' + const secondsSymbol = fullFormat ? '' : 's' const hours = Math.floor(seconds / 3600) - if (hours >= 1) time = hours + hourSymbol - else if (full) time = '0' + hourSymbol + if (hours >= 1 && hours < 10 && fullFormat) time = '0' + hours + hourSymbol + else if (hours >= 1) time = hours + hourSymbol + else if (fullFormat) time = '00' + hourSymbol seconds %= 3600 const minutes = Math.floor(seconds / 60) - if (minutes >= 1 && minutes < 10 && full) time += '0' + minutes + minuteSymbol + if (minutes >= 1 && minutes < 10 && fullFormat) time += '0' + minutes + minuteSymbol else if (minutes >= 1) time += minutes + minuteSymbol - else if (full) time += '00' + minuteSymbol + else if (fullFormat) time += '00' + minuteSymbol seconds %= 60 - if (seconds >= 1 && seconds < 10 && full) time += '0' + seconds + secondsSymbol + if (seconds >= 1 && seconds < 10 && fullFormat) time += '0' + seconds + secondsSymbol else if (seconds >= 1) time += seconds + secondsSymbol - else if (full) time += '00' + else if (fullFormat) time += '00' return time } diff --git a/packages/core-utils/src/renderer/html.ts b/packages/core-utils/src/renderer/html.ts index 365bf7612..fb4f19ac0 100644 --- a/packages/core-utils/src/renderer/html.ts +++ b/packages/core-utils/src/renderer/html.ts @@ -69,3 +69,9 @@ export function escapeHTML (stringParam: string) { return String(stringParam).replace(/[&<>"'`=/]/g, s => entityMap[s]) } + +export function escapeAttribute (value: string) { + if (!value) return '' + + return String(value).replace(/"/g, '\\"') +} diff --git a/packages/ffmpeg/src/ffmpeg-command-wrapper.ts b/packages/ffmpeg/src/ffmpeg-command-wrapper.ts index 647ee3996..4a3b04bf1 100644 --- a/packages/ffmpeg/src/ffmpeg-command-wrapper.ts +++ b/packages/ffmpeg/src/ffmpeg-command-wrapper.ts @@ -79,6 +79,10 @@ export class FFmpegCommandWrapper { // --------------------------------------------------------------------------- + resetCommand () { + this.command = undefined + } + buildCommand (input: string) { if (this.command) throw new Error('Command is already built') diff --git a/packages/ffmpeg/src/ffmpeg-images.ts b/packages/ffmpeg/src/ffmpeg-images.ts index 739c96141..f9f352e3c 100644 --- a/packages/ffmpeg/src/ffmpeg-images.ts +++ b/packages/ffmpeg/src/ffmpeg-images.ts @@ -36,26 +36,51 @@ export class FFmpegImage { return this.commandWrapper.runCommand() } + // --------------------------------------------------------------------------- + async generateThumbnailFromVideo (options: { fromPath: string output: string framesToAnalyze: number ffprobe?: FfprobeData }) { - const { fromPath, output, ffprobe, framesToAnalyze } = options + const { fromPath, ffprobe } = options let duration = await getVideoStreamDuration(fromPath, ffprobe) if (isNaN(duration)) duration = 0 - this.commandWrapper.buildCommand(fromPath) + this.buildGenerateThumbnailFromVideo(options) .seekInput(duration / 2) + + try { + return await this.commandWrapper.runCommand() + } catch (err) { + this.commandWrapper.debugLog('Cannot generate thumbnail from video using seek input, fallback to no seek', { err }) + + this.commandWrapper.resetCommand() + + this.buildGenerateThumbnailFromVideo(options) + + return this.commandWrapper.runCommand() + } + } + + private buildGenerateThumbnailFromVideo (options: { + fromPath: string + output: string + framesToAnalyze: number + }) { + const { fromPath, output, framesToAnalyze } = options + + return this.commandWrapper.buildCommand(fromPath) .videoFilter('thumbnail=' + framesToAnalyze) .outputOption('-frames:v 1') + .outputOption('-abort_on empty_output') .output(output) - - return this.commandWrapper.runCommand() } + // --------------------------------------------------------------------------- + async generateStoryboardFromVideo (options: { path: string destination: string diff --git a/packages/models/src/server/job.model.ts b/packages/models/src/server/job.model.ts index c70add69e..ac90231ef 100644 --- a/packages/models/src/server/job.model.ts +++ b/packages/models/src/server/job.model.ts @@ -269,7 +269,7 @@ export type NotifyPayload = export interface FederateVideoPayload { videoUUID: string - isNewVideo: boolean + isNewVideoForFederation: boolean } // --------------------------------------------------------------------------- diff --git a/packages/server-commands/src/videos/playlists-command.ts b/packages/server-commands/src/videos/playlists-command.ts index 2e483f318..99f3e3c0d 100644 --- a/packages/server-commands/src/videos/playlists-command.ts +++ b/packages/server-commands/src/videos/playlists-command.ts @@ -25,7 +25,7 @@ export class PlaylistsCommand extends AbstractCommand { count?: number sort?: string playlistType?: VideoPlaylistType_Type - }) { + } = {}) { const path = '/api/v1/video-playlists' const query = pick(options, [ 'start', 'count', 'sort', 'playlistType' ]) diff --git a/packages/tests/src/api/live/live-save-replay.ts b/packages/tests/src/api/live/live-save-replay.ts index 84135365b..e439f2397 100644 --- a/packages/tests/src/api/live/live-save-replay.ts +++ b/packages/tests/src/api/live/live-save-replay.ts @@ -66,7 +66,7 @@ describe('Save replay setting', function () { const liveDetails = await servers[0].videos.get({ id: liveVideoUUID }) await waitJobs(servers) - await checkVideosExist(liveVideoUUID, true, HttpStatusCode.OK_200) + await checkVideosExist(liveVideoUUID, null, HttpStatusCode.OK_200) return { ffmpegCommand, liveDetails } } @@ -105,13 +105,14 @@ describe('Save replay setting', function () { return { liveDetails } } - async function checkVideosExist (videoId: string, existsInList: boolean, expectedStatus?: HttpStatusCodeType) { + async function checkVideosExist (videoId: string, videosLength: number, expectedStatus?: HttpStatusCodeType) { for (const server of servers) { - const length = existsInList ? 1 : 0 - const { data, total } = await server.videos.list() - expect(data).to.have.lengthOf(length) - expect(total).to.equal(length) + + if (videosLength !== null) { + expect(data).to.have.lengthOf(videosLength) + expect(total).to.equal(videosLength) + } if (expectedStatus) { await server.videos.get({ id: videoId, expectedStatus }) @@ -172,7 +173,7 @@ describe('Save replay setting', function () { await waitJobs(servers) - await checkVideosExist(liveVideoUUID, false, HttpStatusCode.OK_200) + await checkVideosExist(liveVideoUUID, 0, HttpStatusCode.OK_200) await checkVideoState(liveVideoUUID, VideoState.WAITING_FOR_LIVE) }) @@ -187,7 +188,7 @@ describe('Save replay setting', function () { await waitJobs(servers) - await checkVideosExist(liveVideoUUID, true, HttpStatusCode.OK_200) + await checkVideosExist(liveVideoUUID, 1, HttpStatusCode.OK_200) await checkVideoState(liveVideoUUID, VideoState.PUBLISHED) }) @@ -203,7 +204,7 @@ describe('Save replay setting', function () { await waitJobs(servers) // Live still exist, but cannot be played anymore - await checkVideosExist(liveVideoUUID, false, HttpStatusCode.OK_200) + await checkVideosExist(liveVideoUUID, 0, HttpStatusCode.OK_200) await checkVideoState(liveVideoUUID, VideoState.LIVE_ENDED) // No resolutions saved since we did not save replay @@ -234,7 +235,7 @@ describe('Save replay setting', function () { await publishLiveAndBlacklist({ permanent: false, replay: false }) - await checkVideosExist(liveVideoUUID, false) + await checkVideosExist(liveVideoUUID, 0) await servers[0].videos.get({ id: liveVideoUUID, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) await servers[1].videos.get({ id: liveVideoUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) @@ -258,7 +259,7 @@ describe('Save replay setting', function () { await publishLiveAndDelete({ permanent: false, replay: false }) - await checkVideosExist(liveVideoUUID, false, HttpStatusCode.NOT_FOUND_404) + await checkVideosExist(liveVideoUUID, 0, HttpStatusCode.NOT_FOUND_404) await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: false }) }) }) @@ -272,7 +273,7 @@ describe('Save replay setting', function () { await waitJobs(servers) - await checkVideosExist(liveVideoUUID, false, HttpStatusCode.OK_200) + await checkVideosExist(liveVideoUUID, 0, HttpStatusCode.OK_200) await checkVideoState(liveVideoUUID, VideoState.WAITING_FOR_LIVE) await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.PUBLIC) }) @@ -285,7 +286,7 @@ describe('Save replay setting', function () { await waitJobs(servers) - await checkVideosExist(liveVideoUUID, true, HttpStatusCode.OK_200) + await checkVideosExist(liveVideoUUID, 1, HttpStatusCode.OK_200) await checkVideoState(liveVideoUUID, VideoState.PUBLISHED) await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.PUBLIC) }) @@ -306,7 +307,7 @@ describe('Save replay setting', function () { await waitJobs(servers) // Live has been transcoded - await checkVideosExist(liveVideoUUID, false, HttpStatusCode.OK_200) + await checkVideosExist(liveVideoUUID, 0, HttpStatusCode.OK_200) await checkVideoState(liveVideoUUID, VideoState.PUBLISHED) await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.UNLISTED) }) @@ -354,7 +355,7 @@ describe('Save replay setting', function () { await publishLiveAndBlacklist({ permanent: false, replay: true, replaySettings: { privacy: VideoPrivacy.PUBLIC } }) - await checkVideosExist(liveVideoUUID, false) + await checkVideosExist(liveVideoUUID, 0) await servers[0].videos.get({ id: liveVideoUUID, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) await servers[1].videos.get({ id: liveVideoUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) @@ -369,7 +370,7 @@ describe('Save replay setting', function () { await publishLiveAndDelete({ permanent: false, replay: true, replaySettings: { privacy: VideoPrivacy.PUBLIC } }) - await checkVideosExist(liveVideoUUID, false, HttpStatusCode.NOT_FOUND_404) + await checkVideosExist(liveVideoUUID, 0, HttpStatusCode.NOT_FOUND_404) await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: false }) }) }) @@ -379,6 +380,19 @@ describe('Save replay setting', function () { describe('With a first live and its replay', function () { + before(async function () { + this.timeout(120000) + + await servers[0].kill() + await servers[0].run({ + federation: { + videos: { + federate_unlisted: false + } + } + }) + }) + it('Should correctly create and federate the "waiting for stream" live', async function () { this.timeout(120000) @@ -386,7 +400,7 @@ describe('Save replay setting', function () { await waitJobs(servers) - await checkVideosExist(liveVideoUUID, false, HttpStatusCode.OK_200) + await checkVideosExist(liveVideoUUID, 0, HttpStatusCode.OK_200) await checkVideoState(liveVideoUUID, VideoState.WAITING_FOR_LIVE) await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.PUBLIC) }) @@ -399,12 +413,12 @@ describe('Save replay setting', function () { await waitJobs(servers) - await checkVideosExist(liveVideoUUID, true, HttpStatusCode.OK_200) + await checkVideosExist(liveVideoUUID, 1, HttpStatusCode.OK_200) await checkVideoState(liveVideoUUID, VideoState.PUBLISHED) await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.PUBLIC) }) - it('Should correctly have saved the live and federated it after the streaming', async function () { + it('Should correctly have saved the live', async function () { this.timeout(120000) const liveDetails = await servers[0].videos.get({ id: liveVideoUUID }) @@ -417,13 +431,21 @@ describe('Save replay setting', function () { const video = await findExternalSavedVideo(servers[0], liveDetails) expect(video).to.exist - for (const server of servers) { - await server.videos.get({ id: video.uuid }) - } + await servers[0].videos.get({ id: video.uuid }) + await servers[1].videos.get({ id: video.uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) lastReplayUUID = video.uuid }) + it('Should federate the replay after updating its privacy to public', async function () { + this.timeout(120000) + + await servers[0].videos.update({ id: lastReplayUUID, attributes: { privacy: VideoPrivacy.PUBLIC } }) + await waitJobs(servers) + + await servers[1].videos.get({ id: lastReplayUUID, expectedStatus: HttpStatusCode.OK_200 }) + }) + it('Should have appropriate ended session and replay live session', async function () { const { data, total } = await servers[0].live.listSessions({ videoId: liveVideoUUID }) expect(total).to.equal(1) @@ -449,9 +471,9 @@ describe('Save replay setting', function () { }) it('Should have the first live replay with correct settings', async function () { - await checkVideosExist(lastReplayUUID, false, HttpStatusCode.OK_200) + await checkVideosExist(lastReplayUUID, 1, HttpStatusCode.OK_200) await checkVideoState(lastReplayUUID, VideoState.PUBLISHED) - await checkVideoPrivacy(lastReplayUUID, VideoPrivacy.UNLISTED) + await checkVideoPrivacy(lastReplayUUID, VideoPrivacy.PUBLIC) }) }) @@ -477,7 +499,7 @@ describe('Save replay setting', function () { await waitJobs(servers) - await checkVideosExist(liveVideoUUID, true, HttpStatusCode.OK_200) + await checkVideosExist(liveVideoUUID, 2, HttpStatusCode.OK_200) await checkVideoState(liveVideoUUID, VideoState.PUBLISHED) await checkVideoPrivacy(liveVideoUUID, VideoPrivacy.PUBLIC) }) @@ -527,7 +549,7 @@ describe('Save replay setting', function () { }) it('Should have the first live replay with correct settings', async function () { - await checkVideosExist(lastReplayUUID, true, HttpStatusCode.OK_200) + await checkVideosExist(lastReplayUUID, 2, HttpStatusCode.OK_200) await checkVideoState(lastReplayUUID, VideoState.PUBLISHED) await checkVideoPrivacy(lastReplayUUID, VideoPrivacy.PUBLIC) }) @@ -550,7 +572,7 @@ describe('Save replay setting', function () { expect(replay).to.exist for (const videoId of [ liveVideoUUID, replay.uuid ]) { - await checkVideosExist(videoId, false) + await checkVideosExist(videoId, 1) await servers[0].videos.get({ id: videoId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) await servers[1].videos.get({ id: videoId, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) @@ -571,7 +593,7 @@ describe('Save replay setting', function () { const replay = await findExternalSavedVideo(servers[0], liveDetails) expect(replay).to.not.exist - await checkVideosExist(liveVideoUUID, false, HttpStatusCode.NOT_FOUND_404) + await checkVideosExist(liveVideoUUID, 1, HttpStatusCode.NOT_FOUND_404) await checkLiveCleanup({ server: servers[0], videoUUID: liveVideoUUID, permanent: false }) }) }) diff --git a/packages/tests/src/api/live/live-socket-messages.ts b/packages/tests/src/api/live/live-socket-messages.ts index 80bae154c..dbf1f6a6f 100644 --- a/packages/tests/src/api/live/live-socket-messages.ts +++ b/packages/tests/src/api/live/live-socket-messages.ts @@ -86,9 +86,13 @@ describe('Test live socket messages', function () { await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) await waitJobs(servers) + // Ensure remote server doesn't send multiple times the state change event to viewers + await servers[0].videos.update({ id: liveVideoUUID, attributes: { name: 'my new live name' } }) + await waitJobs(servers) + for (const stateChanges of [ localStateChanges, remoteStateChanges ]) { - expect(stateChanges).to.have.length.at.least(1) - expect(stateChanges[stateChanges.length - 1]).to.equal(VideoState.PUBLISHED) + expect(stateChanges).to.have.lengthOf(1) + expect(stateChanges[0]).to.equal(VideoState.PUBLISHED) } await stopFfmpeg(ffmpegCommand) diff --git a/packages/tests/src/cli/update-host.ts b/packages/tests/src/cli/update-host.ts index e5f165e5e..38b160d2c 100644 --- a/packages/tests/src/cli/update-host.ts +++ b/packages/tests/src/cli/update-host.ts @@ -9,9 +9,11 @@ import { makeActivityPubGetRequest, PeerTubeServer, setAccessTokensToServers, + setDefaultVideoChannel, waitJobs } from '@peertube/peertube-server-commands' import { parseTorrentVideo } from '@tests/shared/webtorrent.js' +import { VideoPlaylistPrivacy } from '@peertube/peertube-models' describe('Test update host scripts', function () { let server: PeerTubeServer @@ -27,6 +29,7 @@ describe('Test update host scripts', function () { // Run server 2 to have transcoding enabled server = await createSingleServer(2, overrideConfig) await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) // Upload two videos for our needs const { uuid: video1UUID } = await server.videos.upload() @@ -47,6 +50,13 @@ describe('Test update host scripts', function () { const text = 'my super first comment' await server.comments.createThread({ videoId: video1UUID, text }) + // Playlist + { + const attributes = { displayName: 'playlist', privacy: VideoPlaylistPrivacy.PUBLIC, videoChannelId: server.store.channel.id } + const playlist = await server.playlists.create({ attributes }) + await server.playlists.addElement({ playlistId: playlist.id, attributes: { videoId: video1UUID } }) + } + await waitJobs(server) }) @@ -100,6 +110,23 @@ describe('Test update host scripts', function () { } }) + it('Should have updated playlist url', async function () { + const body = await server.playlists.list() + expect(body.total).to.equal(1) + + for (const playlist of body.data) { + const { body } = await makeActivityPubGetRequest(server.url, '/video-playlists/' + playlist.uuid) + expect(body.id).to.equal('http://127.0.0.1:9002/video-playlists/' + playlist.uuid) + + const { data: elements } = await server.playlists.listVideos({ playlistId: playlist.id }) + + for (const element of elements) { + const { body } = await makeActivityPubGetRequest(server.url, `/video-playlists/${playlist.uuid}/videos/${element.id}`) + expect(body.id).to.equal(`http://127.0.0.1:9002/video-playlists/${playlist.uuid}/videos/${element.id}`) + } + } + }) + it('Should have updated torrent hosts', async function () { this.timeout(30000) diff --git a/packages/tests/src/client/og-twitter-tags.ts b/packages/tests/src/client/og-twitter-tags.ts index cdd4a15c7..e8ddc7c9b 100644 --- a/packages/tests/src/client/og-twitter-tags.ts +++ b/packages/tests/src/client/og-twitter-tags.ts @@ -265,6 +265,19 @@ describe('Test Open Graph and Twitter cards HTML tags', function () { }) }) + describe('Escaping', function () { + + it('Should correctly escape values', async function () { + await servers[0].users.updateMe({ description: '"super description"' }) + + const res = await makeGetRequest({ url: servers[0].url, path: '/a/root', accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 }) + const text = res.text + + expect(text).to.contain(``) + expect(text).to.contain(``) + }) + }) + after(async function () { await cleanupTests(servers) }) diff --git a/packages/tests/src/nginx.ts b/packages/tests/src/nginx.ts new file mode 100644 index 000000000..b9cdbad8e --- /dev/null +++ b/packages/tests/src/nginx.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode } from '@peertube/peertube-models' +import { + makeRawRequest +} from '@peertube/peertube-server-commands' + +describe('Test nginx', function () { + + it('Should serve public HLS/web video files', async function () { + const urls = [ + // eslint-disable-next-line max-len + 'https://peertube2.cpy.re/static/streaming-playlists/hls/85c8e811-3eb7-4823-8dc5-3c268b6dad60/efad77e7-805d-4b20-8bc9-6e99cee38b20-240-fragmented.mp4', + // eslint-disable-next-line max-len + 'https://peertube2.cpy.re/static/streaming-playlists/hls/85c8e811-3eb7-4823-8dc5-3c268b6dad60/1afbabfa-5f16-452e-8165-fe9a9a21cdb2-master.m3u8', + // eslint-disable-next-line max-len + 'https://peertube2.cpy.re/static/streaming-playlists/hls/85c8e811-3eb7-4823-8dc5-3c268b6dad60/efad77e7-805d-4b20-8bc9-6e99cee38b20-240.m3u8' + ] + + for (const url of urls) { + await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 }) + } + }) + + it('Should not serve private HLS/web video files', async function () { + const urls = [ + // eslint-disable-next-line max-len + 'https://peertube2.cpy.re/static/streaming-playlists/hls/private/72f0e8ee-84b9-44b1-9202-3e72ee7f1b65/531f27fe-bb86-42ed-9cf1-eb5bffc4a609-master.m3u8', + // eslint-disable-next-line max-len + 'https://peertube2.cpy.re/static/streaming-playlists/hls/private/72f0e8ee-84b9-44b1-9202-3e72ee7f1b65/057dbf01-0557-414c-a546-a1cc82ac5d99-480.m3u8', + // eslint-disable-next-line max-len + 'https://peertube2.cpy.re/static/streaming-playlists/hls/private/72f0e8ee-84b9-44b1-9202-3e72ee7f1b65/c9ef3aa7-5ab6-41c5-91c2-058c50a70c3c-segments-sha256.json', + // eslint-disable-next-line max-len + 'https://peertube2.cpy.re/static/streaming-playlists/hls/private/72f0e8ee-84b9-44b1-9202-3e72ee7f1b65/057dbf01-0557-414c-a546-a1cc82ac5d99-480-fragmented.mp4', + 'https://peertube2.cpy.re/static/web-videos/private/72f0e8ee-84b9-44b1-9202-3e72ee7f1b65-480.mp4' + ] + + for (const url of urls) { + await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + } + }) +}) diff --git a/packages/tests/src/server-helpers/core-utils.ts b/packages/tests/src/server-helpers/core-utils.ts index 43afab39a..f1e7c72f7 100644 --- a/packages/tests/src/server-helpers/core-utils.ts +++ b/packages/tests/src/server-helpers/core-utils.ts @@ -78,6 +78,10 @@ describe('Time to int', function () { expect(timeToInt('02h02m03s')).to.equal(7323) expect(timeToInt('2:02:3')).to.equal(7323) + expect(timeToInt('5h10m')).to.equal(5 * 3600 + 60 * 10) + expect(timeToInt('5h10m0s')).to.equal(5 * 3600 + 60 * 10) + expect(timeToInt('5h10m0')).to.equal(5 * 3600 + 60 * 10) + expect(timeToInt(3500)).to.equal(3500) }) }) diff --git a/scripts/ci.sh b/scripts/ci.sh index 9544daf91..3d29b7ae9 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -59,7 +59,7 @@ elif [ "$1" = "client" ]; then feedsFiles=$(findTestFiles ./packages/tests/dist/feeds) clientFiles=$(findTestFiles ./packages/tests/dist/client) - miscFiles="./packages/tests/dist/misc-endpoints.js" + miscFiles="./packages/tests/dist/misc-endpoints.js ./packages/tests/dist/nginx.js" # Not in their own task, they need an index.html pluginFiles="./packages/tests/dist/plugins/html-injection.js ./packages/tests/dist/api/server/plugins.js" diff --git a/scripts/dev/embed.sh b/scripts/dev/embed.sh index e694950e6..5732b37d0 100755 --- a/scripts/dev/embed.sh +++ b/scripts/dev/embed.sh @@ -2,6 +2,8 @@ set -eu +npm run build:server + npm run concurrently -- -k \ "cd client && npm run webpack -- --config webpack/webpack.video-embed.js --mode development --watch" \ - "npm run build:server && NODE_ENV=dev npm start" + "NODE_ENV=dev npm start" diff --git a/server/core/controllers/api/videos/source.ts b/server/core/controllers/api/videos/source.ts index ccba63060..b4bfc3725 100644 --- a/server/core/controllers/api/videos/source.ts +++ b/server/core/controllers/api/videos/source.ts @@ -165,7 +165,7 @@ async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVide type: 'federate-video' as 'federate-video', payload: { videoUUID: video.uuid, - isNewVideo: false + isNewVideoForFederation: false } } ] diff --git a/server/core/controllers/api/videos/update.ts b/server/core/controllers/api/videos/update.ts index badc60be3..ee0598fea 100644 --- a/server/core/controllers/api/videos/update.ts +++ b/server/core/controllers/api/videos/update.ts @@ -64,7 +64,7 @@ async function updateVideo (req: express.Request, res: express.Response) { const videoFileLockReleaser = await VideoPathManager.Instance.lockFiles(videoFromReq.uuid) try { - const { videoInstanceUpdated, isNewVideo } = await sequelizeTypescript.transaction(async t => { + const { videoInstanceUpdated, isNewVideoForFederation } = await sequelizeTypescript.transaction(async t => { // Refresh video since thumbnails to prevent concurrent updates const video = await VideoModel.loadFull(videoFromReq.id, t) @@ -96,9 +96,15 @@ async function updateVideo (req: express.Request, res: express.Response) { } // Privacy update? - let isNewVideo = false + let isNewVideoForFederation = false + if (videoInfoToUpdate.privacy !== undefined) { - isNewVideo = await updateVideoPrivacy({ videoInstance: video, videoInfoToUpdate, hadPrivacyForFederation, transaction: t }) + isNewVideoForFederation = await updateVideoPrivacy({ + videoInstance: video, + videoInfoToUpdate, + hadPrivacyForFederation, + transaction: t + }) } // Force updatedAt attribute change @@ -155,7 +161,7 @@ async function updateVideo (req: express.Request, res: express.Response) { ) logger.info('Video with name %s and uuid %s updated.', video.name, video.uuid, lTags(video.uuid)) - return { videoInstanceUpdated, isNewVideo } + return { videoInstanceUpdated, isNewVideoForFederation } }) Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated, body: req.body, req, res }) @@ -164,7 +170,7 @@ async function updateVideo (req: express.Request, res: express.Response) { video: videoInstanceUpdated, nameChanged: !!videoInfoToUpdate.name, oldPrivacy, - isNewVideo + isNewVideoForFederation }) } catch (err) { // If the transaction is retried, sequelize will think the object has not changed @@ -181,6 +187,7 @@ async function updateVideo (req: express.Request, res: express.Response) { .end() } +// Return a boolean indicating if the video is considered as "new" for remote instances in the federation async function updateVideoPrivacy (options: { videoInstance: MVideoFullLight videoInfoToUpdate: VideoUpdate @@ -188,7 +195,7 @@ async function updateVideoPrivacy (options: { transaction: Transaction }) { const { videoInstance, videoInfoToUpdate, hadPrivacyForFederation, transaction } = options - const isNewVideo = videoInstance.isNewVideo(videoInfoToUpdate.privacy) + const isNewVideoForFederation = videoInstance.isNewVideoForFederation(videoInfoToUpdate.privacy) const newPrivacy = forceNumber(videoInfoToUpdate.privacy) as VideoPrivacyType setVideoPrivacy(videoInstance, newPrivacy) @@ -208,7 +215,7 @@ async function updateVideoPrivacy (options: { await VideoModel.sendDelete(videoInstance, { transaction }) } - return isNewVideo + return isNewVideoForFederation } function updateSchedule (videoInstance: MVideoFullLight, videoInfoToUpdate: VideoUpdate, transaction: Transaction) { diff --git a/server/core/controllers/api/videos/upload.ts b/server/core/controllers/api/videos/upload.ts index 195b0ef30..a1a0d5058 100644 --- a/server/core/controllers/api/videos/upload.ts +++ b/server/core/controllers/api/videos/upload.ts @@ -269,7 +269,7 @@ async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVide type: 'federate-video' as 'federate-video', payload: { videoUUID: video.uuid, - isNewVideo: true + isNewVideoForFederation: true } } ] diff --git a/server/core/helpers/webtorrent.ts b/server/core/helpers/webtorrent.ts index c0ac26644..15d4a832a 100644 --- a/server/core/helpers/webtorrent.ts +++ b/server/core/helpers/webtorrent.ts @@ -36,7 +36,10 @@ async function downloadWebTorrentVideo (target: { uri: string, torrentName?: str await ensureDir(directoryPath) // eslint-disable-next-line new-cap - const webtorrent = new (await import('webtorrent')).default() + const webtorrent = new (await import('webtorrent')).default({ + natUpnp: false, + natPmp: false + } as any) return new Promise((res, rej) => { let file: TorrentFile diff --git a/server/core/lib/activitypub/videos/updater.ts b/server/core/lib/activitypub/videos/updater.ts index f9c5b4040..d40ddab31 100644 --- a/server/core/lib/activitypub/videos/updater.ts +++ b/server/core/lib/activitypub/videos/updater.ts @@ -53,6 +53,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder { this.checkChannelUpdateOrThrow(channelActor) + const oldState = this.video.state const videoUpdated = await this.updateVideo(channelActor.VideoChannel, undefined, overrideTo) if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel) @@ -95,7 +96,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder { Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated) } - if (videoUpdated.isLive) { + if (videoUpdated.isLive && oldState !== videoUpdated.state) { PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated) } diff --git a/server/core/lib/html/shared/tags-html.ts b/server/core/lib/html/shared/tags-html.ts index 3df840fd9..97f337d48 100644 --- a/server/core/lib/html/shared/tags-html.ts +++ b/server/core/lib/html/shared/tags-html.ts @@ -1,4 +1,4 @@ -import { escapeHTML } from '@peertube/peertube-core-utils' +import { escapeAttribute, escapeHTML } from '@peertube/peertube-core-utils' import { CONFIG } from '../../../initializers/config.js' import { CUSTOM_HTML_TAG_COMMENTS, EMBED_SIZE, WEBSERVER } from '../../../initializers/constants.js' import { MVideo, MVideoPlaylist } from '../../../types/models/index.js' @@ -61,7 +61,7 @@ export class TagsHtml { static addDescriptionTag (htmlStringPage: string, escapedTruncatedDescription?: string) { const content = escapedTruncatedDescription || escapeHTML(CONFIG.INSTANCE.SHORT_DESCRIPTION) - const descriptionTag = `` + const descriptionTag = `` return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag) } @@ -93,7 +93,7 @@ export class TagsHtml { const tagValue = openGraphMetaTags[tagName] if (!tagValue) return - tagsStr += `` + tagsStr += `` }) // Standard @@ -101,7 +101,7 @@ export class TagsHtml { const tagValue = standardMetaTags[tagName] if (!tagValue) return - tagsStr += `` + tagsStr += `` }) // Twitter card @@ -109,12 +109,13 @@ export class TagsHtml { const tagValue = twitterCardMetaTags[tagName] if (!tagValue) return - tagsStr += `` + tagsStr += `` }) // OEmbed for (const oembedLinkTag of oembedLinkTags) { - tagsStr += `` + // eslint-disable-next-line max-len + tagsStr += `` } // Schema.org diff --git a/server/core/lib/job-queue/handlers/federate-video.ts b/server/core/lib/job-queue/handlers/federate-video.ts index 270ce6cc5..8972d92d4 100644 --- a/server/core/lib/job-queue/handlers/federate-video.ts +++ b/server/core/lib/job-queue/handlers/federate-video.ts @@ -16,7 +16,7 @@ function processFederateVideo (job: Job) { const video = await VideoModel.loadFull(payload.videoUUID, t) if (!video) return - return federateVideoIfNeeded(video, payload.isNewVideo, t) + return federateVideoIfNeeded(video, payload.isNewVideoForFederation, t) }) }) } diff --git a/server/core/lib/job-queue/handlers/video-live-ending.ts b/server/core/lib/job-queue/handlers/video-live-ending.ts index 1837f9d33..6012ac127 100644 --- a/server/core/lib/job-queue/handlers/video-live-ending.ts +++ b/server/core/lib/job-queue/handlers/video-live-ending.ts @@ -60,6 +60,12 @@ async function processVideoLiveEnding (job: Job) { return cleanupLiveAndFederate({ permanentLive, video, streamingPlaylistId: payload.streamingPlaylistId }) } + if (await hasReplayFiles(payload.replayDirectory) !== true) { + logger.info(`No replay files found for live ${video.uuid}, skipping video replay creation.`, { ...lTags(video.uuid) }) + + return cleanupLiveAndFederate({ permanentLive, video, streamingPlaylistId: payload.streamingPlaylistId }) + } + if (permanentLive) { await saveReplayToExternalVideo({ liveVideo: video, @@ -310,3 +316,7 @@ function createStoryboardJob (video: MVideo) { } }) } + +async function hasReplayFiles (replayDirectory: string) { + return (await readdir(replayDirectory)).length !== 0 +} diff --git a/server/core/lib/object-storage/pre-signed-urls.ts b/server/core/lib/object-storage/pre-signed-urls.ts index bbb19a57c..8da98e246 100644 --- a/server/core/lib/object-storage/pre-signed-urls.ts +++ b/server/core/lib/object-storage/pre-signed-urls.ts @@ -18,7 +18,7 @@ export async function generateWebVideoPresignedUrl (options: { const command = new GetObjectCommand({ Bucket: CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BUCKET_NAME, Key: buildKey(key, CONFIG.OBJECT_STORAGE.WEB_VIDEOS), - ResponseContentDisposition: `attachment; filename=${downloadFilename}` + ResponseContentDisposition: `attachment; filename=${encodeURI(downloadFilename)}` }) const url = await getSignedUrl(await getClient(), command, { expiresIn: 3600 * 24 }) @@ -41,7 +41,7 @@ export async function generateHLSFilePresignedUrl (options: { const command = new GetObjectCommand({ Bucket: CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME, Key: buildKey(key, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS), - ResponseContentDisposition: `attachment; filename=${downloadFilename}` + ResponseContentDisposition: `attachment; filename=${encodeURI(downloadFilename)}` }) const url = await getSignedUrl(await getClient(), command, { expiresIn: 3600 * 24 }) diff --git a/server/core/lib/schedulers/update-videos-scheduler.ts b/server/core/lib/schedulers/update-videos-scheduler.ts index 2d4892013..50ecb019c 100644 --- a/server/core/lib/schedulers/update-videos-scheduler.ts +++ b/server/core/lib/schedulers/update-videos-scheduler.ts @@ -48,7 +48,7 @@ export class UpdateVideosScheduler extends AbstractScheduler { private async updateAVideo (schedule: MScheduleVideoUpdate) { let oldPrivacy: VideoPrivacyType - let isNewVideo: boolean + let isNewVideoForFederation: boolean let published = false const video = await sequelizeTypescript.transaction(async t => { @@ -58,7 +58,7 @@ export class UpdateVideosScheduler extends AbstractScheduler { logger.info('Executing scheduled video update on %s.', video.uuid) if (schedule.privacy) { - isNewVideo = video.isNewVideo(schedule.privacy) + isNewVideoForFederation = video.isNewVideoForFederation(schedule.privacy) oldPrivacy = video.privacy setVideoPrivacy(video, schedule.privacy) @@ -78,7 +78,7 @@ export class UpdateVideosScheduler extends AbstractScheduler { return { video, published: false } } - await addVideoJobsAfterUpdate({ video, oldPrivacy, isNewVideo, nameChanged: false }) + await addVideoJobsAfterUpdate({ video, oldPrivacy, isNewVideoForFederation, nameChanged: false }) return { video, published } } diff --git a/server/core/lib/video-chapters.ts b/server/core/lib/video-chapters.ts index 71e2a7cf4..740493f4a 100644 --- a/server/core/lib/video-chapters.ts +++ b/server/core/lib/video-chapters.ts @@ -79,12 +79,18 @@ async function createChapters (options: { }) { const { chapters, transaction, videoId } = options + const existingTimecodes = new Set() + for (const chapter of chapters) { + if (existingTimecodes.has(chapter.timecode)) continue + await VideoChapterModel.create({ title: chapter.title, timecode: chapter.timecode, videoId }, { transaction }) + + existingTimecodes.add(chapter.timecode) } } diff --git a/server/core/lib/video-studio.ts b/server/core/lib/video-studio.ts index a8cb19f0d..d248a0f23 100644 --- a/server/core/lib/video-studio.ts +++ b/server/core/lib/video-studio.ts @@ -118,7 +118,7 @@ export async function onVideoStudioEnded (options: { type: 'federate-video' as 'federate-video', payload: { videoUUID: video.uuid, - isNewVideo: false + isNewVideoForFederation: false } }, diff --git a/server/core/lib/video.ts b/server/core/lib/video.ts index 3664ac4e9..705cb39b4 100644 --- a/server/core/lib/video.ts +++ b/server/core/lib/video.ts @@ -138,12 +138,12 @@ export const getCachedVideoDuration = memoizee(getVideoDuration, { export async function addVideoJobsAfterUpdate (options: { video: MVideoFullLight - isNewVideo: boolean + isNewVideoForFederation: boolean nameChanged: boolean oldPrivacy: VideoPrivacyType }) { - const { video, nameChanged, oldPrivacy, isNewVideo } = options + const { video, nameChanged, oldPrivacy, isNewVideoForFederation } = options const jobs: CreateJobArgument[] = [] const filePathChanged = await moveFilesIfPrivacyChanged(video, oldPrivacy) @@ -168,7 +168,7 @@ export async function addVideoJobsAfterUpdate (options: { type: 'federate-video', payload: { videoUUID: video.uuid, - isNewVideo + isNewVideoForFederation } }) diff --git a/server/core/middlewares/validators/videos/videos.ts b/server/core/middlewares/validators/videos/videos.ts index 44ea62534..0c139eb47 100644 --- a/server/core/middlewares/validators/videos/videos.ts +++ b/server/core/middlewares/validators/videos/videos.ts @@ -232,7 +232,7 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([ if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req) const video = getVideoWithAttributes(res) - if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) { + if (exists(req.body.privacy) && video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) { return res.fail({ message: 'Cannot update privacy of a live that has already started' }) } diff --git a/server/core/models/video/video.ts b/server/core/models/video/video.ts index 7c64ee66f..131b1f597 100644 --- a/server/core/models/video/video.ts +++ b/server/core/models/video/video.ts @@ -2009,7 +2009,7 @@ export class VideoModel extends Model>> { return isStateForFederation(this.state) } - isNewVideo (newPrivacy: VideoPrivacyType) { + isNewVideoForFederation (newPrivacy: VideoPrivacyType) { return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true } diff --git a/server/core/models/view/local-video-viewer-watch-section.ts b/server/core/models/view/local-video-viewer-watch-section.ts index b04dcf4bd..eeb7fafd3 100644 --- a/server/core/models/view/local-video-viewer-watch-section.ts +++ b/server/core/models/view/local-video-viewer-watch-section.ts @@ -50,8 +50,8 @@ export class LocalVideoViewerWatchSectionModel extends Model process.exit(0)) @@ -122,6 +126,44 @@ async function run () { await comment.save() } + console.log('Updating video playlists.') + const videoPlaylists: VideoPlaylistModel[] = await VideoPlaylistModel.findAll({ + include: [ + { + model: AccountModel.unscoped(), + required: true, + include: [ + { + model: ActorModel.unscoped(), + where: { + serverId: null + }, + required: true + } + ] + } + ] + }) + for (const playlist of videoPlaylists) { + console.log('Updating video playlist ' + playlist.url) + + playlist.url = getLocalVideoPlaylistActivityPubUrl(playlist) + await playlist.save() + + const elements: VideoPlaylistElementModel[] = await VideoPlaylistElementModel.findAll({ + where: { + videoPlaylistId: playlist.id + } + }) + + for (const element of elements) { + console.log('Updating video playlist element ' + element.url) + + element.url = getLocalVideoPlaylistElementActivityPubUrl(playlist, element) + await element.save() + } + } + console.log('Updating video and torrent files.') const ids = await VideoModel.listLocalIds() diff --git a/support/nginx/peertube b/support/nginx/peertube index d5cf89e56..ba4388883 100644 --- a/support/nginx/peertube +++ b/support/nginx/peertube @@ -199,7 +199,7 @@ server { alias /var/www/peertube/peertube-latest/client/dist/$1; } - location ~ ^(/static/(webseed|web-videos|streaming-playlists)/private/)|^/download { + location ~ ^(/static/(webseed|web-videos|streaming-playlists/hls)/private/)|^/download { # We can't rate limit a try_files directive, so we need to duplicate @api proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;