diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html index f3c1f1634..8342562c3 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html @@ -230,6 +230,57 @@ + + Chapters + + +
+
+ +
+ +
+ + + + + +
+ + +
{{ i + 1 }}
+ + + +
+ + +
+ t + {{ formErrors.chapters[i].title }} +
+
+ + +
+
+ +
+ {{ getChapterArrayErrors() }} +
+
+ + +
+
+
+ Live settings @@ -312,7 +363,6 @@ - Advanced settings 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 b0c053019..a81d62dd1 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 @@ -117,6 +117,32 @@ p-calendar { @include orange-button; } +.hide-chapter-label { + height: 0; + opacity: 0; +} + +.chapter { + display: grid; + grid-template-columns: auto auto minmax(150px, 350px) 1fr; + grid-template-rows: auto auto; + column-gap: 1rem; + + .position { + height: 31px; + display: flex; + align-items: center; + } + + my-delete-button { + width: fit-content; + } + + .form-error { + margin-top: 0; + } +} + @include on-small-main-col { .form-columns { grid-template-columns: 1fr; diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts index 898d3b0a6..35beba5b1 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts @@ -2,10 +2,10 @@ import { forkJoin } from 'rxjs' import { map } from 'rxjs/operators' import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model' import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' -import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms' +import { AbstractControl, FormArray, FormGroup, Validators } from '@angular/forms' import { HooksService, PluginService, ServerService } from '@app/core' import { removeElementFromArray } from '@app/helpers' -import { BuildFormValidator } from '@app/shared/form-validators' +import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators' import { VIDEO_CATEGORY_VALIDATOR, VIDEO_CHANNEL_VALIDATOR, @@ -20,9 +20,10 @@ import { VIDEO_SUPPORT_VALIDATOR, VIDEO_TAGS_ARRAY_VALIDATOR } from '@app/shared/form-validators/video-validators' -import { FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms' +import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators' +import { FormReactiveErrors, FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms' import { InstanceService } from '@app/shared/shared-instance' -import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoEdit, VideoService } from '@app/shared/shared-main' +import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoChaptersEdit, VideoEdit, VideoService } from '@app/shared/shared-main' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { HTMLServerConfig, @@ -30,6 +31,7 @@ import { LiveVideoLatencyMode, RegisterClientFormFieldOptions, RegisterClientVideoFieldOptions, + VideoChapter, VideoConstant, VideoDetails, VideoPrivacy, @@ -57,7 +59,7 @@ type PluginField = { }) export class VideoEditComponent implements OnInit, OnDestroy { @Input() form: FormGroup - @Input() formErrors: { [ id: string ]: string } = {} + @Input() formErrors: FormReactiveErrors & { chapters?: { title: string }[] } = {} @Input() validationMessages: FormReactiveValidationMessages = {} @Input() videoToUpdate: VideoDetails @@ -68,6 +70,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { @Input() videoCaptions: VideoCaptionWithPathEdit[] = [] @Input() videoSource: VideoSource + @Input() videoChapters: VideoChapter[] = [] + @Input() hideWaitTranscoding = false @Input() updateVideoFileEnabled = false @@ -150,7 +154,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { licence: this.serverConfig.defaults.publish.licence, tags: [] } - const obj: { [ id: string ]: BuildFormValidator } = { + const obj: BuildFormArgument = { name: VIDEO_NAME_VALIDATOR, privacy: VIDEO_PRIVACY_VALIDATOR, videoPassword: VIDEO_PASSWORD_VALIDATOR, @@ -183,12 +187,16 @@ export class VideoEditComponent implements OnInit, OnDestroy { defaultValues ) - this.form.addControl('captions', new FormArray([ - new FormGroup({ - language: new FormControl(), - captionfile: new FormControl() - }) - ])) + this.form.addControl('chapters', new FormArray([], VIDEO_CHAPTERS_ARRAY_VALIDATOR.VALIDATORS)) + this.addNewChapterControl() + + this.form.get('chapters').valueChanges.subscribe((chapters: { title: string, timecode: string }[]) => { + const lastChapter = chapters[chapters.length - 1] + + if (lastChapter.title || lastChapter.timecode) { + this.addNewChapterControl() + } + }) this.trackChannelChange() this.trackPrivacyChange() @@ -426,6 +434,70 @@ export class VideoEditComponent implements OnInit, OnDestroy { this.form.valueChanges.subscribe(() => this.formValidatorService.updateTreeValidity(this.pluginDataFormGroup)) } + // --------------------------------------------------------------------------- + + addNewChapterControl () { + const chaptersFormArray = this.getChaptersFormArray() + const controls = chaptersFormArray.controls + + if (controls.length !== 0) { + const lastControl = chaptersFormArray.controls[controls.length - 1] + lastControl.get('title').addValidators(Validators.required) + } + + this.formValidatorService.addControlInFormArray({ + controlName: 'chapters', + formArray: chaptersFormArray, + formErrors: this.formErrors, + validationMessages: this.validationMessages, + formToBuild: { + timecode: null, + title: VIDEO_CHAPTER_TITLE_VALIDATOR + }, + defaultValues: { + timecode: 0 + } + }) + } + + getChaptersFormArray () { + return this.form.controls['chapters'] as FormArray + } + + deleteChapterControl (index: number) { + this.formValidatorService.removeControlFromFormArray({ + controlName: 'chapters', + formArray: this.getChaptersFormArray(), + formErrors: this.formErrors, + validationMessages: this.validationMessages, + index + }) + } + + isLastChapterControl (index: number) { + return this.getChaptersFormArray().length - 1 === index + } + + patchChapters (chaptersEdit: VideoChaptersEdit) { + const totalChapters = chaptersEdit.getChaptersForUpdate().length + const totalControls = this.getChaptersFormArray().length + + // Add missing controls. We use <= because we need the "empty control" to add another chapter + for (let i = 0; i <= totalChapters - totalControls; i++) { + this.addNewChapterControl() + } + + this.form.patchValue(chaptersEdit.toFormPatch()) + } + + getChapterArrayErrors () { + if (!this.getChaptersFormArray().errors) return '' + + return Object.values(this.getChaptersFormArray().errors).join('. ') + } + + // --------------------------------------------------------------------------- + private trackPrivacyChange () { // We will update the schedule input and the wait transcoding checkbox validators this.form.controls['privacy'] @@ -469,8 +541,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { } else { videoPasswordControl.clearValidators() } - videoPasswordControl.updateValueAndValidity() + videoPasswordControl.updateValueAndValidity() } ) } diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts index f7a570ed3..69d12b85f 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts @@ -4,7 +4,7 @@ import { Router } from '@angular/router' import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' import { scrollToTop } from '@app/helpers' import { FormReactiveService } from '@app/shared/shared-forms' -import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' +import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main' import { LiveVideoService } from '@app/shared/shared-video-live' import { LoadingBarService } from '@ngx-loading-bar/core' import { logger } from '@root-helpers/logger' @@ -54,6 +54,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView protected serverService: ServerService, protected videoService: VideoService, protected videoCaptionService: VideoCaptionService, + protected videoChapterService: VideoChapterService, private liveVideoService: LiveVideoService, private router: Router, private hooks: HooksService @@ -137,6 +138,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView video.uuid = this.videoUUID video.shortUUID = this.videoShortUUID + this.chaptersEdit.patch(this.form.value) + const saveReplay = this.form.value.saveReplay const replaySettings = saveReplay ? { privacy: this.form.value.replayPrivacy } @@ -151,7 +154,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView // Update the video forkJoin([ - this.updateVideoAndCaptions(video), + this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions }), this.liveVideoService.updateLive(this.videoId, liveVideoUpdate) ]).subscribe({ diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts index 97517e1c7..50eb14c6e 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts @@ -4,7 +4,7 @@ import { Router } from '@angular/router' import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' import { scrollToTop } from '@app/helpers' import { FormReactiveService } from '@app/shared/shared-forms' -import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' +import { VideoCaptionService, VideoChapterService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' import { LoadingBarService } from '@ngx-loading-bar/core' import { logger } from '@root-helpers/logger' import { PeerTubeProblemDocument, ServerErrorCode, VideoUpdate } from '@peertube/peertube-models' @@ -42,6 +42,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af protected serverService: ServerService, protected videoService: VideoService, protected videoCaptionService: VideoCaptionService, + protected videoChapterService: VideoChapterService, private router: Router, private videoImportService: VideoImportService, private hooks: HooksService @@ -124,24 +125,25 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af if (!await this.isFormValid()) return this.video.patch(this.form.value) + this.chaptersEdit.patch(this.form.value) this.isUpdatingVideo = true // Update the video - this.updateVideoAndCaptions(this.video) - .subscribe({ - next: () => { - this.isUpdatingVideo = false - this.notifier.success($localize`Video to import updated.`) + this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit }) + .subscribe({ + next: () => { + this.isUpdatingVideo = false + this.notifier.success($localize`Video to import updated.`) - this.router.navigate([ '/my-library', 'video-imports' ]) - }, + this.router.navigate([ '/my-library', 'video-imports' ]) + }, - error: err => { - this.error = err.message - scrollToTop() - logger.error(err) - } - }) + error: err => { + this.error = err.message + scrollToTop() + logger.error(err) + } + }) } } diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html index a80d31aaf..30eeca704 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html +++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html @@ -56,6 +56,7 @@
() @Output() firstStepError = new EventEmitter() @@ -41,6 +44,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV protected serverService: ServerService, protected videoService: VideoService, protected videoCaptionService: VideoCaptionService, + protected videoChapterService: VideoChapterService, private router: Router, private videoImportService: VideoImportService, private hooks: HooksService @@ -85,12 +89,13 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV switchMap(previous => { return forkJoin([ this.videoCaptionService.listCaptions(previous.video.uuid), + this.videoChapterService.getChapters({ videoId: previous.video.uuid }), this.videoService.getVideo({ videoId: previous.video.uuid }) - ]).pipe(map(([ videoCaptionsResult, video ]) => ({ videoCaptions: videoCaptionsResult.data, video }))) + ]).pipe(map(([ videoCaptionsResult, { chapters }, video ]) => ({ videoCaptions: videoCaptionsResult.data, chapters, video }))) }) ) .subscribe({ - next: ({ video, videoCaptions }) => { + next: ({ video, videoCaptions, chapters }) => { this.loadingBar.useRef().complete() this.firstStepDone.emit(video.name) this.isImportingVideo = false @@ -99,9 +104,12 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV this.video = new VideoEdit(video) this.video.patch({ privacy: this.firstStepPrivacyId }) + this.chaptersEdit.loadFromAPI(chapters) + this.videoCaptions = videoCaptions hydrateFormFromVideo(this.form, this.video, true) + setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit)) }, error: err => { @@ -117,11 +125,12 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV if (!await this.isFormValid()) return this.video.patch(this.form.value) + this.chaptersEdit.patch(this.form.value) this.isUpdatingVideo = true // Update the video - this.updateVideoAndCaptions(this.video) + this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit }) .subscribe({ next: () => { this.isUpdatingVideo = false diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-send.ts b/client/src/app/+videos/+video-edit/video-add-components/video-send.ts index 56dcfa0e6..2c38e11a3 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-send.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-send.ts @@ -4,9 +4,17 @@ import { Directive, EventEmitter, OnInit } from '@angular/core' import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core' import { listUserChannelsForSelect } from '@app/helpers' import { FormReactive } from '@app/shared/shared-forms' -import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' +import { + VideoCaptionEdit, + VideoCaptionService, + VideoChapterService, + VideoChaptersEdit, + VideoEdit, + VideoService +} from '@app/shared/shared-main' import { LoadingBarService } from '@ngx-loading-bar/core' import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models' +import { of } from 'rxjs' @Directive() // eslint-disable-next-line @angular-eslint/directive-class-suffix @@ -14,6 +22,7 @@ export abstract class VideoSend extends FormReactive implements OnInit { userVideoChannels: SelectChannelItem[] = [] videoPrivacies: VideoConstant[] = [] videoCaptions: VideoCaptionEdit[] = [] + chaptersEdit = new VideoChaptersEdit() firstStepPrivacyId: VideoPrivacyType firstStepChannelId: number @@ -28,6 +37,7 @@ export abstract class VideoSend extends FormReactive implements OnInit { protected serverService: ServerService protected videoService: VideoService protected videoCaptionService: VideoCaptionService + protected videoChapterService: VideoChapterService protected serverConfig: HTMLServerConfig @@ -60,13 +70,23 @@ export abstract class VideoSend extends FormReactive implements OnInit { }) } - protected updateVideoAndCaptions (video: VideoEdit) { + protected updateVideoAndCaptionsAndChapters (options: { + video: VideoEdit + captions: VideoCaptionEdit[] + chapters?: VideoChaptersEdit + }) { + const { video, captions, chapters } = options + this.loadingBar.useRef().start() return this.videoService.updateVideo(video) .pipe( - // Then update captions - switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions)), + switchMap(() => this.videoCaptionService.updateCaptions(video.uuid, captions)), + switchMap(() => { + return chapters + ? this.videoChapterService.updateChapters(video.uuid, chapters) + : of(true) + }), tap(() => this.loadingBar.useRef().complete()), catchError(err => { this.loadingBar.useRef().complete() diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts index cbf43ee5f..cc0dcc1ae 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts @@ -7,7 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router' import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core' import { genericUploadErrorHandler, scrollToTop } from '@app/helpers' import { FormReactiveService } from '@app/shared/shared-forms' -import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' +import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main' import { LoadingBarService } from '@ngx-loading-bar/core' import { logger } from '@root-helpers/logger' import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models' @@ -63,6 +63,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy protected serverService: ServerService, protected videoService: VideoService, protected videoCaptionService: VideoCaptionService, + protected videoChapterService: VideoChapterService, private userService: UserService, private router: Router, private hooks: HooksService, @@ -241,9 +242,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy video.uuid = this.videoUploadedIds.uuid video.shortUUID = this.videoUploadedIds.shortUUID + this.chaptersEdit.patch(this.form.value) + this.isUpdatingVideo = true - this.updateVideoAndCaptions(video) + this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions, chapters: this.chaptersEdit }) .subscribe({ next: () => { this.isUpdatingVideo = false diff --git a/client/src/app/+videos/+video-edit/video-update.component.html b/client/src/app/+videos/+video-edit/video-update.component.html index 9a99c0c3d..2f667658c 100644 --- a/client/src/app/+videos/+video-edit/video-update.component.html +++ b/client/src/app/+videos/+video-edit/video-update.component.html @@ -13,6 +13,7 @@ this.onUploadVideoOngoing(state)) const { videoData } = this.route.snapshot.data - const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData + const { video, videoChannels, videoCaptions, videoChapters, videoSource, liveVideo, videoPassword } = videoData this.videoDetails = video this.videoEdit = new VideoEdit(this.videoDetails, videoPassword) + this.chaptersEdit.loadFromAPI(videoChapters) this.userVideoChannels = videoChannels this.videoCaptions = videoCaptions @@ -106,6 +122,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest onFormBuilt () { hydrateFormFromVideo(this.form, this.videoEdit, true) + setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit)) + if (this.liveVideo) { this.form.patchValue({ saveReplay: this.liveVideo.saveReplay, @@ -172,6 +190,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest if (!await this.checkAndConfirmVideoFileReplacement()) return this.videoEdit.patch(this.form.value) + this.chaptersEdit.patch(this.form.value) this.abortUpdateIfNeeded() @@ -180,10 +199,12 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest this.updateSubcription = this.videoReplacementUploadedSubject.pipe( switchMap(() => this.videoService.updateVideo(this.videoEdit)), + switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.uuid, this.videoCaptions)), + switchMap(() => { + if (this.liveVideo) return of(true) - // Then update captions - switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)), - + return this.videoChapterService.updateChapters(this.videoEdit.uuid, this.chaptersEdit) + }), switchMap(() => { if (!this.liveVideo) return of(undefined) diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts index d114bfb2d..0293f3c71 100644 --- a/client/src/app/+videos/+video-edit/video-update.resolver.ts +++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts @@ -4,7 +4,7 @@ import { Injectable } from '@angular/core' import { ActivatedRouteSnapshot } from '@angular/router' import { AuthService } from '@app/core' import { listUserChannelsForSelect } from '@app/helpers' -import { VideoCaptionService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main' +import { VideoCaptionService, VideoChapterService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main' import { LiveVideoService } from '@app/shared/shared-video-live' import { VideoPrivacy } from '@peertube/peertube-models' @@ -15,6 +15,7 @@ export class VideoUpdateResolver { private liveVideoService: LiveVideoService, private authService: AuthService, private videoCaptionService: VideoCaptionService, + private videoChapterService: VideoChapterService, private videoPasswordService: VideoPasswordService ) { } @@ -25,8 +26,8 @@ export class VideoUpdateResolver { return this.videoService.getVideo({ videoId: uuid }) .pipe( switchMap(video => forkJoin(this.buildVideoObservables(video))), - map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) => - ({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword })) + map(([ video, videoSource, videoChannels, videoCaptions, videoChapters, liveVideo, videoPassword ]) => + ({ video, videoChannels, videoCaptions, videoChapters, videoSource, liveVideo, videoPassword })) ) } @@ -46,6 +47,12 @@ export class VideoUpdateResolver { map(result => result.data) ), + this.videoChapterService + .getChapters({ videoId: video.uuid }) + .pipe( + map(({ chapters }) => chapters) + ), + video.isLive ? this.liveVideoService.getVideoLive(video.id) : of(undefined), diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts index febb3c828..39c9c7986 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.ts +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts @@ -18,7 +18,7 @@ import { } from '@app/core' import { HooksService } from '@app/core/plugins/hooks.service' import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers' -import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' +import { Video, VideoCaptionService, VideoChapterService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' import { LiveVideoService } from '@app/shared/shared-video-live' import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' @@ -31,6 +31,7 @@ import { ServerErrorCode, Storyboard, VideoCaption, + VideoChapter, VideoPrivacy, VideoState, VideoStateType @@ -83,6 +84,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video: VideoDetails = null videoCaptions: VideoCaption[] = [] + videoChapters: VideoChapter[] = [] liveVideo: LiveVideo videoPassword: string storyboards: Storyboard[] = [] @@ -125,6 +127,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { private notifier: Notifier, private zone: NgZone, private videoCaptionService: VideoCaptionService, + private videoChapterService: VideoChapterService, private hotkeysService: HotkeysService, private hooks: HooksService, private pluginService: PluginService, @@ -306,14 +309,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy { forkJoin([ videoAndLiveObs, this.videoCaptionService.listCaptions(videoId, videoPassword), + this.videoChapterService.getChapters({ videoId, videoPassword }), this.videoService.getStoryboards(videoId, videoPassword), this.userService.getAnonymousOrLoggedUser() ]).subscribe({ - next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => { + next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, loggedInOrAnonymousUser ]) => { this.onVideoFetched({ video, live, videoCaptions: captionsResult.data, + videoChapters: chaptersResult.chapters, storyboards, videoFileToken, videoPassword, @@ -411,6 +416,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video: VideoDetails live: LiveVideo videoCaptions: VideoCaption[] + videoChapters: VideoChapter[] storyboards: Storyboard[] videoFileToken: string videoPassword: string @@ -422,6 +428,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video, live, videoCaptions, + videoChapters, storyboards, videoFileToken, videoPassword, @@ -433,6 +440,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.video = video this.videoCaptions = videoCaptions + this.videoChapters = videoChapters this.liveVideo = live this.videoFileToken = videoFileToken this.videoPassword = videoPassword @@ -480,6 +488,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { const params = { video: this.video, videoCaptions: this.videoCaptions, + videoChapters: this.videoChapters, storyboards: this.storyboards, liveVideo: this.liveVideo, videoFileToken: this.videoFileToken, @@ -636,6 +645,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video: VideoDetails liveVideo: LiveVideo videoCaptions: VideoCaption[] + videoChapters: VideoChapter[] storyboards: Storyboard[] videoFileToken: string @@ -651,6 +661,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video, liveVideo, videoCaptions, + videoChapters, storyboards, videoFileToken, videoPassword, @@ -750,6 +761,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { videoPassword: () => videoPassword, videoCaptions: playerCaptions, + videoChapters, storyboard, videoShortUUID: video.shortUUID, diff --git a/client/src/app/helpers/utils/object.ts b/client/src/app/helpers/utils/object.ts index b69e31edf..ef1acd298 100644 --- a/client/src/app/helpers/utils/object.ts +++ b/client/src/app/helpers/utils/object.ts @@ -7,17 +7,6 @@ function removeElementFromArray (arr: T[], elem: T) { if (index !== -1) arr.splice(index, 1) } -function sortBy (obj: any[], key1: string, key2?: string) { - return obj.sort((a, b) => { - const elem1 = key2 ? a[key1][key2] : a[key1] - const elem2 = key2 ? b[key1][key2] : b[key1] - - if (elem1 < elem2) return -1 - if (elem1 === elem2) return 0 - return 1 - }) -} - function splitIntoArray (value: any) { if (!value) return undefined if (Array.isArray(value)) return value @@ -41,7 +30,6 @@ function toBoolean (value: any) { } export { - sortBy, immutableAssign, removeElementFromArray, splitIntoArray, diff --git a/client/src/app/menu/language-chooser.component.ts b/client/src/app/menu/language-chooser.component.ts index 1ec5987c2..978a4af39 100644 --- a/client/src/app/menu/language-chooser.component.ts +++ b/client/src/app/menu/language-chooser.component.ts @@ -1,7 +1,7 @@ import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' -import { getDevLocale, isOnDevLocale, sortBy } from '@app/helpers' +import { getDevLocale, isOnDevLocale } from '@app/helpers' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped } from '@peertube/peertube-core-utils' +import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped, sortBy } from '@peertube/peertube-core-utils' @Component({ selector: 'my-language-chooser', diff --git a/client/src/app/shared/form-validators/video-chapter-validators.ts b/client/src/app/shared/form-validators/video-chapter-validators.ts new file mode 100644 index 000000000..cbbd9291e --- /dev/null +++ b/client/src/app/shared/form-validators/video-chapter-validators.ts @@ -0,0 +1,32 @@ +import { AbstractControl, ValidationErrors, ValidatorFn, Validators } from '@angular/forms' +import { BuildFormValidator } from './form-validator.model' + +export const VIDEO_CHAPTER_TITLE_VALIDATOR: BuildFormValidator = { + VALIDATORS: [ Validators.minLength(2), Validators.maxLength(100) ], // Required is set dynamically + MESSAGES: { + required: $localize`A chapter title is required.`, + minlength: $localize`A chapter title should be more than 2 characters long.`, + maxlength: $localize`A chapter title should be less than 100 characters long.` + } +} + +export const VIDEO_CHAPTERS_ARRAY_VALIDATOR: BuildFormValidator = { + VALIDATORS: [ uniqueTimecodeValidator() ], + MESSAGES: {} +} + +function uniqueTimecodeValidator (): ValidatorFn { + return (control: AbstractControl): ValidationErrors => { + const array = control.value as { timecode: number, title: string }[] + + for (const chapter of array) { + if (!chapter.title) continue + + if (array.filter(c => c.title && c.timecode === chapter.timecode).length > 1) { + return { uniqueTimecode: $localize`Multiple chapters have the same timecode ${chapter.timecode}` } + } + } + + return null + } +} diff --git a/client/src/app/shared/form-validators/video-validators.ts b/client/src/app/shared/form-validators/video-validators.ts index 090a76e43..a434c777f 100644 --- a/client/src/app/shared/form-validators/video-validators.ts +++ b/client/src/app/shared/form-validators/video-validators.ts @@ -70,14 +70,6 @@ export const VIDEO_DESCRIPTION_VALIDATOR: BuildFormValidator = { } } -export const VIDEO_TAG_VALIDATOR: BuildFormValidator = { - VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ], - MESSAGES: { - minlength: $localize`A tag should be more than 2 characters long.`, - maxlength: $localize`A tag should be less than 30 characters long.` - } -} - export const VIDEO_TAGS_ARRAY_VALIDATOR: BuildFormValidator = { VALIDATORS: [ Validators.maxLength(5), arrayTagLengthValidator() ], MESSAGES: { diff --git a/client/src/app/shared/shared-forms/form-reactive.service.ts b/client/src/app/shared/shared-forms/form-reactive.service.ts index f1b7e0ef2..b960c310e 100644 --- a/client/src/app/shared/shared-forms/form-reactive.service.ts +++ b/client/src/app/shared/shared-forms/form-reactive.service.ts @@ -4,9 +4,9 @@ import { wait } from '@root-helpers/utils' import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' import { FormValidatorService } from './form-validator.service' -export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors } +export type FormReactiveErrors = { [ id: string | number ]: string | FormReactiveErrors | FormReactiveErrors[] } export type FormReactiveValidationMessages = { - [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages + [ id: string | number ]: { [ name: string ]: string } | FormReactiveValidationMessages | FormReactiveValidationMessages[] } @Injectable() @@ -86,7 +86,7 @@ export class FormReactiveService { if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue - const staticMessages = validationMessages[field] + const staticMessages = validationMessages[field] as FormReactiveValidationMessages for (const key of Object.keys(control.errors)) { const formErrorValue = control.errors[key] diff --git a/client/src/app/shared/shared-forms/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validator.service.ts index e7dedf52a..d810285bb 100644 --- a/client/src/app/shared/shared-forms/form-validator.service.ts +++ b/client/src/app/shared/shared-forms/form-validator.service.ts @@ -45,20 +45,20 @@ export class FormValidatorService { form: FormGroup, formErrors: FormReactiveErrors, validationMessages: FormReactiveValidationMessages, - obj: BuildFormArgument, + formToBuild: BuildFormArgument, defaultValues: BuildFormDefaultValues = {} ) { - for (const name of objectKeysTyped(obj)) { + for (const name of objectKeysTyped(formToBuild)) { formErrors[name] = '' - const field = obj[name] + const field = formToBuild[name] if (this.isRecursiveField(field)) { this.updateFormGroup( // FIXME: typings (form as any)[name], formErrors[name] as FormReactiveErrors, validationMessages[name] as FormReactiveValidationMessages, - obj[name] as BuildFormArgument, + formToBuild[name] as BuildFormArgument, defaultValues[name] as BuildFormDefaultValues ) continue @@ -66,7 +66,7 @@ export class FormValidatorService { if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } - const defaultValue = defaultValues[name] || '' + const defaultValue = defaultValues[name] ?? '' form.addControl( name + '', @@ -75,6 +75,55 @@ export class FormValidatorService { } } + addControlInFormArray (options: { + formErrors: FormReactiveErrors + validationMessages: FormReactiveValidationMessages + formArray: FormArray + controlName: string + formToBuild: BuildFormArgument + defaultValues?: BuildFormDefaultValues + }) { + const { formArray, formErrors, validationMessages, controlName, formToBuild, defaultValues = {} } = options + + const formGroup = new FormGroup({}) + if (!formErrors[controlName]) formErrors[controlName] = [] as FormReactiveErrors[] + if (!validationMessages[controlName]) validationMessages[controlName] = [] + + const formArrayErrors = formErrors[controlName] as FormReactiveErrors[] + const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[] + + const totalControls = formArray.controls.length + formArrayErrors.push({}) + formArrayValidationMessages.push({}) + + this.updateFormGroup( + formGroup, + formArrayErrors[totalControls], + formArrayValidationMessages[totalControls], + formToBuild, + defaultValues + ) + + formArray.push(formGroup) + } + + removeControlFromFormArray (options: { + formErrors: FormReactiveErrors + validationMessages: FormReactiveValidationMessages + index: number + formArray: FormArray + controlName: string + }) { + const { formArray, formErrors, validationMessages, index, controlName } = options + + const formArrayErrors = formErrors[controlName] as FormReactiveErrors[] + const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[] + + formArrayErrors.splice(index, 1) + formArrayValidationMessages.splice(index, 1) + formArray.removeAt(index) + } + updateTreeValidity (group: FormGroup | FormArray): void { for (const key of Object.keys(group.controls)) { // FIXME: typings diff --git a/client/src/app/shared/shared-forms/input-text.component.ts b/client/src/app/shared/shared-forms/input-text.component.ts index be03f25b9..2f3c8f603 100644 --- a/client/src/app/shared/shared-forms/input-text.component.ts +++ b/client/src/app/shared/shared-forms/input-text.component.ts @@ -1,6 +1,6 @@ import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' -import { Notifier } from '@app/core' +import { FormReactiveErrors } from './form-reactive.service' @Component({ selector: 'my-input-text', @@ -26,9 +26,7 @@ export class InputTextComponent implements ControlValueAccessor { @Input() withCopy = false @Input() readonly = false @Input() show = false - @Input() formError: string - - constructor (private notifier: Notifier) { } + @Input() formError: string | FormReactiveErrors | FormReactiveErrors[] get inputType () { return this.show diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.html b/client/src/app/shared/shared-forms/markdown-textarea.component.html index ac2dfd17c..7f8bd2f62 100644 --- a/client/src/app/shared/shared-forms/markdown-textarea.component.html +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.html @@ -25,7 +25,7 @@ - diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts index 243394bda..30c6cabf5 100644 --- a/client/src/app/shared/shared-main/shared-main.module.ts +++ b/client/src/app/shared/shared-main/shared-main.module.ts @@ -49,6 +49,7 @@ import { UserHistoryService, UserNotificationsComponent, UserNotificationService import { EmbedComponent, RedundancyService, + VideoChapterService, VideoFileTokenService, VideoImportService, VideoOwnershipService, @@ -215,6 +216,8 @@ import { VideoChannelService } from './video-channel' VideoPasswordService, + VideoChapterService, + CustomPageService, ActorRedirectGuard diff --git a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts index 59c0969a9..5e4a27d4e 100644 --- a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts +++ b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts @@ -3,9 +3,9 @@ import { catchError, map, switchMap } from 'rxjs/operators' import { HttpClient } from '@angular/common/http' import { Injectable } from '@angular/core' import { RestExtractor, ServerService } from '@app/core' -import { objectToFormData, sortBy } from '@app/helpers' +import { objectToFormData } from '@app/helpers' import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video' -import { peertubeTranslate } from '@peertube/peertube-core-utils' +import { peertubeTranslate, sortBy } from '@peertube/peertube-core-utils' import { ResultList, VideoCaption } from '@peertube/peertube-models' import { environment } from '../../../../environments/environment' import { VideoCaptionEdit } from './video-caption-edit.model' diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts index 07d40b117..7414ded23 100644 --- a/client/src/app/shared/shared-main/video/index.ts +++ b/client/src/app/shared/shared-main/video/index.ts @@ -1,5 +1,7 @@ export * from './embed.component' export * from './redundancy.service' +export * from './video-chapter.service' +export * from './video-chapters-edit.model' export * from './video-details.model' export * from './video-edit.model' export * from './video-file-token.service' diff --git a/client/src/app/shared/shared-main/video/video-chapter.service.ts b/client/src/app/shared/shared-main/video/video-chapter.service.ts new file mode 100644 index 000000000..6d221c9e9 --- /dev/null +++ b/client/src/app/shared/shared-main/video/video-chapter.service.ts @@ -0,0 +1,34 @@ +import { catchError } from 'rxjs/operators' +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor } from '@app/core' +import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models' +import { VideoPasswordService } from './video-password.service' +import { VideoService } from './video.service' +import { VideoChaptersEdit } from './video-chapters-edit.model' +import { of } from 'rxjs' + +@Injectable() +export class VideoChapterService { + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor + ) {} + + getChapters (options: { videoId: string, videoPassword?: string }) { + const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword) + + return this.authHttp.get<{ chapters: VideoChapter[] }>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}/chapters`, { headers }) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + updateChapters (videoId: string, chaptersEdit: VideoChaptersEdit) { + if (chaptersEdit.shouldUpdateAPI() !== true) return of(true) + + const body = { chapters: chaptersEdit.getChaptersForUpdate() } as VideoChapterUpdate + + return this.authHttp.put(`${VideoService.BASE_VIDEO_URL}/${videoId}/chapters`, body) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } +} diff --git a/client/src/app/shared/shared-main/video/video-chapters-edit.model.ts b/client/src/app/shared/shared-main/video/video-chapters-edit.model.ts new file mode 100644 index 000000000..6d7496ed6 --- /dev/null +++ b/client/src/app/shared/shared-main/video/video-chapters-edit.model.ts @@ -0,0 +1,43 @@ +import { simpleObjectsDeepEqual, sortBy } from '@peertube/peertube-core-utils' +import { VideoChapter } from '@peertube/peertube-models' + +export class VideoChaptersEdit { + private chaptersFromAPI: VideoChapter[] = [] + + private chapters: VideoChapter[] + + loadFromAPI (chapters: VideoChapter[]) { + this.chapters = chapters || [] + + this.chaptersFromAPI = chapters + } + + patch (values: { [ id: string ]: any }) { + const chapters = values.chapters || [] + + this.chapters = chapters.map((c: any) => { + return { + timecode: c.timecode || 0, + title: c.title + } + }) + } + + toFormPatch () { + return { chapters: this.chapters } + } + + getChaptersForUpdate (): VideoChapter[] { + return this.chapters.filter(c => !!c.title) + } + + hasDuplicateValues () { + const timecodes = this.chapters.map(c => c.timecode) + + return new Set(timecodes).size !== this.chapters.length + } + + shouldUpdateAPI () { + return simpleObjectsDeepEqual(sortBy(this.getChaptersForUpdate(), 'timecode'), this.chaptersFromAPI) !== true + } +} diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts index 111b4645b..192b2e124 100644 --- a/client/src/assets/player/peertube-player.ts +++ b/client/src/assets/player/peertube-player.ts @@ -7,6 +7,8 @@ import './shared/bezels/bezels-plugin' import './shared/peertube/peertube-plugin' import './shared/resolutions/peertube-resolutions-plugin' import './shared/control-bar/storyboard-plugin' +import './shared/control-bar/chapters-plugin' +import './shared/control-bar/time-tooltip' import './shared/control-bar/next-previous-video-button' import './shared/control-bar/p2p-info-button' import './shared/control-bar/peertube-link-button' @@ -227,6 +229,7 @@ export class PeerTubePlayer { if (this.player.usingPlugin('upnext')) this.player.upnext().dispose() if (this.player.usingPlugin('stats')) this.player.stats().dispose() if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose() + if (this.player.usingPlugin('chapters')) this.player.chapters().dispose() if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose() @@ -273,6 +276,10 @@ export class PeerTubePlayer { this.player.storyboard(this.currentLoadOptions.storyboard) } + if (this.currentLoadOptions.videoChapters) { + this.player.chapters({ chapters: this.currentLoadOptions.videoChapters }) + } + if (this.currentLoadOptions.dock) { this.player.peertubeDock(this.currentLoadOptions.dock) } diff --git a/client/src/assets/player/shared/control-bar/chapters-plugin.ts b/client/src/assets/player/shared/control-bar/chapters-plugin.ts new file mode 100644 index 000000000..5be081694 --- /dev/null +++ b/client/src/assets/player/shared/control-bar/chapters-plugin.ts @@ -0,0 +1,64 @@ +import videojs from 'video.js' +import { ChaptersOptions } from '../../types' +import { VideoChapter } from '@peertube/peertube-models' +import { ProgressBarMarkerComponent } from './progress-bar-marker-component' + +const Plugin = videojs.getPlugin('plugin') + +class ChaptersPlugin extends Plugin { + private chapters: VideoChapter[] = [] + private markers: ProgressBarMarkerComponent[] = [] + + constructor (player: videojs.Player, options: videojs.ComponentOptions & ChaptersOptions) { + super(player, options) + + this.chapters = options.chapters + + this.player.ready(() => { + player.addClass('vjs-chapters') + + this.player.one('durationchange', () => { + for (const chapter of this.chapters) { + if (chapter.timecode === 0) continue + + const marker = new ProgressBarMarkerComponent(player, { timecode: chapter.timecode }) + + this.markers.push(marker) + this.getSeekBar().addChild(marker) + } + }) + }) + } + + dispose () { + for (const marker of this.markers) { + this.getSeekBar().removeChild(marker) + } + } + + getChapter (timecode: number) { + if (this.chapters.length !== 0) { + for (let i = this.chapters.length - 1; i >= 0; i--) { + const chapter = this.chapters[i] + + if (chapter.timecode <= timecode) { + this.player.addClass('has-chapter') + + return chapter.title + } + } + } + + this.player.removeClass('has-chapter') + + return '' + } + + private getSeekBar () { + return this.player.getDescendant('ControlBar', 'ProgressControl', 'SeekBar') + } +} + +videojs.registerPlugin('chapters', ChaptersPlugin) + +export { ChaptersPlugin } diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts index 9307027f6..091e876e2 100644 --- a/client/src/assets/player/shared/control-bar/index.ts +++ b/client/src/assets/player/shared/control-bar/index.ts @@ -1,6 +1,8 @@ +export * from './chapters-plugin' export * from './next-previous-video-button' export * from './p2p-info-button' export * from './peertube-link-button' export * from './peertube-live-display' export * from './storyboard-plugin' export * from './theater-button' +export * from './time-tooltip' diff --git a/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts b/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts new file mode 100644 index 000000000..50965ec71 --- /dev/null +++ b/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts @@ -0,0 +1,24 @@ +import videojs from 'video.js' +import { ProgressBarMarkerComponentOptions } from '../../types' + +const Component = videojs.getComponent('Component') + +export class ProgressBarMarkerComponent extends Component { + options_: ProgressBarMarkerComponentOptions & videojs.ComponentOptions + + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor (player: videojs.Player, options?: ProgressBarMarkerComponentOptions & videojs.ComponentOptions) { + super(player, options) + } + + createEl () { + const left = (this.options_.timecode / this.player().duration()) * 100 + + return videojs.dom.createEl('span', { + className: 'vjs-marker', + style: `left: ${left}%` + }) as HTMLButtonElement + } +} + +videojs.registerComponent('ProgressBarMarkerComponent', ProgressBarMarkerComponent) diff --git a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts index 80c69b5f2..91d7f451e 100644 --- a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts +++ b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts @@ -141,7 +141,9 @@ class StoryboardPlugin extends Plugin { const ctop = Math.floor(position / columns) * -scaledHeight const bgSize = `${imgWidth * scaleFactor}px ${imgHeight * scaleFactor}px` - const topOffset = -scaledHeight - 60 + + const timeTooltip = this.player.el().querySelector('.vjs-time-tooltip') + const topOffset = -scaledHeight + parseInt(getComputedStyle(timeTooltip).top.replace('px', '')) - 20 const previewHalfSize = Math.round(scaledWidth / 2) let left = seekBarRect.width * seekBarX - previewHalfSize diff --git a/client/src/assets/player/shared/control-bar/time-tooltip.ts b/client/src/assets/player/shared/control-bar/time-tooltip.ts new file mode 100644 index 000000000..2ed4f9acd --- /dev/null +++ b/client/src/assets/player/shared/control-bar/time-tooltip.ts @@ -0,0 +1,20 @@ +import { timeToInt } from '@peertube/peertube-core-utils' +import videojs, { VideoJsPlayer } from 'video.js' + +const TimeToolTip = videojs.getComponent('TimeTooltip') as any // FIXME: typings don't have write method + +class TimeTooltip extends TimeToolTip { + + write (timecode: string) { + const player: VideoJsPlayer = this.player() + + if (player.usingPlugin('chapters')) { + const chapterTitle = player.chapters().getChapter(timeToInt(timecode)) + if (chapterTitle) return super.write(chapterTitle + '\r\n' + timecode) + } + + return super.write(timecode) + } +} + +videojs.registerComponent('TimeTooltip', TimeTooltip) diff --git a/client/src/assets/player/types/peertube-player-options.ts b/client/src/assets/player/types/peertube-player-options.ts index 6fb2f7913..32f26fa9e 100644 --- a/client/src/assets/player/types/peertube-player-options.ts +++ b/client/src/assets/player/types/peertube-player-options.ts @@ -1,4 +1,4 @@ -import { LiveVideoLatencyModeType, VideoFile } from '@peertube/peertube-models' +import { LiveVideoLatencyModeType, VideoChapter, VideoFile } from '@peertube/peertube-models' import { PluginsManager } from '@root-helpers/plugins-manager' import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' @@ -68,6 +68,7 @@ export type PeerTubePlayerLoadOptions = { } videoCaptions: VideoJSCaption[] + videoChapters: VideoChapter[] storyboard: VideoJSStoryboard videoUUID: string diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts index 27fbda31d..6293404ab 100644 --- a/client/src/assets/player/types/peertube-videojs-typings.ts +++ b/client/src/assets/player/types/peertube-videojs-typings.ts @@ -1,7 +1,7 @@ import { HlsConfig, Level } from 'hls.js' import videojs from 'video.js' import { Engine } from '@peertube/p2p-media-loader-hlsjs' -import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' +import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' import { BezelsPlugin } from '../shared/bezels/bezels-plugin' import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' @@ -19,6 +19,7 @@ import { UpNextPlugin } from '../shared/upnext/upnext-plugin' import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' import { PlayerMode } from './peertube-player-options' import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' +import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin' declare module 'video.js' { @@ -62,6 +63,8 @@ declare module 'video.js' { peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin + chapters (options?: ChaptersOptions): ChaptersPlugin + upnext (options?: UpNextPluginOptions): UpNextPlugin playlist (options?: PlaylistPluginOptions): PlaylistPlugin @@ -142,6 +145,10 @@ type StoryboardOptions = { interval: number } +type ChaptersOptions = { + chapters: VideoChapter[] +} + type PlaylistPluginOptions = { elements: VideoPlaylistElement[] @@ -161,6 +168,10 @@ type UpNextPluginOptions = { isSuspended: () => boolean } +type ProgressBarMarkerComponentOptions = { + timecode: number +} + type NextPreviousVideoButtonOptions = { type: 'next' | 'previous' handler?: () => void @@ -273,6 +284,7 @@ export { NextPreviousVideoButtonOptions, ResolutionUpdateData, AutoResolutionUpdateData, + ProgressBarMarkerComponentOptions, PlaylistPluginOptions, MetricsPluginOptions, VideoJSCaption, @@ -284,5 +296,6 @@ export { UpNextPluginOptions, LoadedQualityData, StoryboardOptions, + ChaptersOptions, PeerTubeLinkButtonOptions } diff --git a/client/src/sass/player/control-bar.scss b/client/src/sass/player/control-bar.scss index 09a75e2fd..f272f3848 100644 --- a/client/src/sass/player/control-bar.scss +++ b/client/src/sass/player/control-bar.scss @@ -3,6 +3,16 @@ @use '_mixins' as *; @use './_player-variables' as *; +.vjs-peertube-skin.has-chapter { + .vjs-time-tooltip { + white-space: pre; + line-height: 1.5; + padding-top: 4px; + padding-bottom: 4px; + top: -4.9em; + } +} + .video-js.vjs-peertube-skin .vjs-control-bar { z-index: 100; @@ -495,3 +505,12 @@ } } } + +.vjs-marker { + position: absolute; + width: 3px; + opacity: .5; + background-color: #000; + height: 100%; + top: 0; +} diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index e4f723079..78c5e5592 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts @@ -195,10 +195,11 @@ export class PeerTubeEmbed { const { videoResponse, captionsPromise, + chaptersPromise, storyboardsPromise } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword }) - return this.buildVideoPlayer({ videoResponse, captionsPromise, storyboardsPromise, forceAutoplay }) + return this.buildVideoPlayer({ videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay }) } catch (err) { if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options }) @@ -210,9 +211,10 @@ export class PeerTubeEmbed { videoResponse: Response storyboardsPromise: Promise captionsPromise: Promise + chaptersPromise: Promise forceAutoplay: boolean }) { - const { videoResponse, captionsPromise, storyboardsPromise, forceAutoplay } = options + const { videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay } = options const videoInfoPromise = videoResponse.json() .then(async (videoInfo: VideoDetails) => { @@ -233,11 +235,13 @@ export class PeerTubeEmbed { { video, live, videoFileToken }, translations, captionsResponse, + chaptersResponse, storyboardsResponse ] = await Promise.all([ videoInfoPromise, this.translationsPromise, captionsPromise, + chaptersPromise, storyboardsPromise, this.buildPlayerIfNeeded() ]) @@ -260,6 +264,7 @@ export class PeerTubeEmbed { const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({ video, captionsResponse, + chaptersResponse, translations, storyboardsResponse, diff --git a/client/src/standalone/videos/shared/player-options-builder.ts b/client/src/standalone/videos/shared/player-options-builder.ts index 3437ef421..dec859409 100644 --- a/client/src/standalone/videos/shared/player-options-builder.ts +++ b/client/src/standalone/videos/shared/player-options-builder.ts @@ -5,6 +5,7 @@ import { Storyboard, Video, VideoCaption, + VideoChapter, VideoDetails, VideoPlaylistElement, VideoState, @@ -199,6 +200,8 @@ export class PlayerOptionsBuilder { storyboardsResponse: Response + chaptersResponse: Response + live?: LiveVideo alreadyPlayed: boolean @@ -229,12 +232,14 @@ export class PlayerOptionsBuilder { forceAutoplay, playlist, live, - storyboardsResponse + storyboardsResponse, + chaptersResponse } = options - const [ videoCaptions, storyboard ] = await Promise.all([ + const [ videoCaptions, storyboard, chapters ] = await Promise.all([ this.buildCaptions(captionsResponse, translations), - this.buildStoryboard(storyboardsResponse) + this.buildStoryboard(storyboardsResponse), + this.buildChapters(chaptersResponse) ]) return { @@ -248,6 +253,7 @@ export class PlayerOptionsBuilder { subtitle: this.subtitle, storyboard, + videoChapters: chapters, startTime: playlist ? playlist.playlistTracker.getCurrentElement().startTimestamp @@ -312,6 +318,12 @@ export class PlayerOptionsBuilder { } } + private async buildChapters (chaptersResponse: Response) { + const { chapters } = await chaptersResponse.json() as { chapters: VideoChapter[] } + + return chapters + } + private buildPlaylistOptions (options?: { playlistTracker: PlaylistTracker playNext: () => any diff --git a/client/src/standalone/videos/shared/video-fetcher.ts b/client/src/standalone/videos/shared/video-fetcher.ts index 9149d946e..c52861189 100644 --- a/client/src/standalone/videos/shared/video-fetcher.ts +++ b/client/src/standalone/videos/shared/video-fetcher.ts @@ -36,9 +36,10 @@ export class VideoFetcher { } const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword }) + const chaptersPromise = this.loadVideoChapters({ videoId, videoPassword }) const storyboardsPromise = this.loadStoryboards(videoId) - return { captionsPromise, storyboardsPromise, videoResponse } + return { captionsPromise, chaptersPromise, storyboardsPromise, videoResponse } } loadLive (video: VideoDetails) { @@ -64,6 +65,10 @@ export class VideoFetcher { return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword) } + private loadVideoChapters ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise { + return this.http.fetch(this.getVideoUrl(videoId) + '/chapters', { optionalAuth: true }, videoPassword) + } + private getVideoUrl (id: string) { return window.location.origin + '/api/v1/videos/' + id } diff --git a/packages/core-utils/src/common/array.ts b/packages/core-utils/src/common/array.ts index 878ed1ffe..3978ddd16 100644 --- a/packages/core-utils/src/common/array.ts +++ b/packages/core-utils/src/common/array.ts @@ -1,4 +1,4 @@ -function findCommonElement (array1: T[], array2: T[]) { +export function findCommonElement (array1: T[], array2: T[]) { for (const a of array1) { for (const b of array2) { if (a === b) return a @@ -9,19 +9,19 @@ function findCommonElement (array1: T[], array2: T[]) { } // Avoid conflict with other toArray() functions -function arrayify (element: T | T[]) { +export function arrayify (element: T | T[]) { if (Array.isArray(element)) return element return [ element ] } // Avoid conflict with other uniq() functions -function uniqify (elements: T[]) { +export function uniqify (elements: T[]) { return Array.from(new Set(elements)) } // Thanks: https://stackoverflow.com/a/12646864 -function shuffle (elements: T[]) { +export function shuffle (elements: T[]) { const shuffled = [ ...elements ] for (let i = shuffled.length - 1; i > 0; i--) { @@ -33,9 +33,13 @@ function shuffle (elements: T[]) { return shuffled } -export { - uniqify, - findCommonElement, - shuffle, - arrayify +export function sortBy (obj: any[], key1: string, key2?: string) { + return obj.sort((a, b) => { + const elem1 = key2 ? a[key1][key2] : a[key1] + const elem2 = key2 ? b[key1][key2] : b[key1] + + if (elem1 < elem2) return -1 + if (elem1 === elem2) return 0 + return 1 + }) } diff --git a/packages/core-utils/src/common/date.ts b/packages/core-utils/src/common/date.ts index f0684ff86..66899de80 100644 --- a/packages/core-utils/src/common/date.ts +++ b/packages/core-utils/src/common/date.ts @@ -45,11 +45,13 @@ function isLastWeek (d: Date) { // --------------------------------------------------------------------------- +export const timecodeRegexString = `((\\d+)[h:])?((\\d+)[m:])?((\\d+)s?)?` + function timeToInt (time: number | string) { if (!time) return 0 if (typeof time === 'number') return time - const reg = /^((\d+)[h:])?((\d+)[m:])?((\d+)s?)?$/ + const reg = new RegExp(`^${timecodeRegexString}$`) const matches = time.match(reg) if (!matches) return 0 diff --git a/packages/core-utils/src/index.ts b/packages/core-utils/src/index.ts index 3ca5d9d47..69fa2c046 100644 --- a/packages/core-utils/src/index.ts +++ b/packages/core-utils/src/index.ts @@ -5,3 +5,4 @@ export * from './plugins/index.js' export * from './renderer/index.js' export * from './users/index.js' export * from './videos/index.js' +export * from './string/index.js' diff --git a/packages/core-utils/src/string/chapters.ts b/packages/core-utils/src/string/chapters.ts new file mode 100644 index 000000000..d7643665c --- /dev/null +++ b/packages/core-utils/src/string/chapters.ts @@ -0,0 +1,32 @@ +import { timeToInt, timecodeRegexString } from '../common/date.js' + +const timecodeRegex = new RegExp(`^(${timecodeRegexString})\\s`) + +export function parseChapters (text: string) { + if (!text) return [] + + const lines = text.split(/\r?\n|\r|\n/g) + let foundChapters = false + + const chapters: { timecode: number, title: string }[] = [] + + for (const line of lines) { + const matched = line.match(timecodeRegex) + if (!matched) { + // Stop chapters parsing + if (foundChapters) break + + continue + } + + foundChapters = true + + const timecodeText = matched[1] + const timecode = timeToInt(timecodeText) + const title = line.replace(matched[0], '') + + chapters.push({ timecode, title }) + } + + return chapters +} diff --git a/packages/core-utils/src/string/index.ts b/packages/core-utils/src/string/index.ts new file mode 100644 index 000000000..42680ab16 --- /dev/null +++ b/packages/core-utils/src/string/index.ts @@ -0,0 +1 @@ +export * from './chapters.js' diff --git a/packages/ffmpeg/src/ffprobe.ts b/packages/ffmpeg/src/ffprobe.ts index ed1742ab1..f995e7925 100644 --- a/packages/ffmpeg/src/ffprobe.ts +++ b/packages/ffmpeg/src/ffprobe.ts @@ -10,7 +10,7 @@ import { VideoResolution } from '@peertube/peertube-models' function ffprobePromise (path: string) { return new Promise((res, rej) => { - ffmpeg.ffprobe(path, (err, data) => { + ffmpeg.ffprobe(path, [ '-show_chapters' ], (err, data) => { if (err) return rej(err) return res(data) @@ -168,10 +168,27 @@ async function getVideoStream (path: string, existingProbe?: FfprobeData) { return metadata.streams.find(s => s.codec_type === 'video') } +// --------------------------------------------------------------------------- +// Chapters +// --------------------------------------------------------------------------- + +async function getChaptersFromContainer (path: string, existingProbe?: FfprobeData) { + const metadata = existingProbe || await ffprobePromise(path) + + if (!Array.isArray(metadata?.chapters)) return [] + + return metadata.chapters + .map(c => ({ + timecode: c.start_time, + title: c['TAG:title'] + })) +} + // --------------------------------------------------------------------------- export { getVideoStreamDimensionsInfo, + getChaptersFromContainer, getMaxAudioBitrate, getVideoStream, getVideoStreamDuration, diff --git a/packages/models/src/activitypub/context.ts b/packages/models/src/activitypub/context.ts index e9df38207..e52463c6c 100644 --- a/packages/models/src/activitypub/context.ts +++ b/packages/models/src/activitypub/context.ts @@ -13,4 +13,5 @@ export type ContextType = 'Flag' | 'Actor' | 'Collection' | - 'WatchAction' + 'WatchAction' | + 'Chapters' diff --git a/packages/models/src/activitypub/objects/index.ts b/packages/models/src/activitypub/objects/index.ts index 510f621ea..8e21f584f 100644 --- a/packages/models/src/activitypub/objects/index.ts +++ b/packages/models/src/activitypub/objects/index.ts @@ -4,6 +4,7 @@ export * from './cache-file-object.js' export * from './common-objects.js' export * from './playlist-element-object.js' export * from './playlist-object.js' +export * from './video-chapters-object.js' export * from './video-comment-object.js' export * from './video-object.js' export * from './watch-action-object.js' diff --git a/packages/models/src/activitypub/objects/video-chapters-object.ts b/packages/models/src/activitypub/objects/video-chapters-object.ts new file mode 100644 index 000000000..0149c6e87 --- /dev/null +++ b/packages/models/src/activitypub/objects/video-chapters-object.ts @@ -0,0 +1,11 @@ +export interface VideoChaptersObject { + id: string + hasPart: VideoChapterObject[] +} + +// Same as https://schema.org/hasPart +export interface VideoChapterObject { + name: string + startOffset: number + endOffset: number +} diff --git a/packages/models/src/activitypub/objects/video-object.ts b/packages/models/src/activitypub/objects/video-object.ts index 14afd85a2..9abae6a39 100644 --- a/packages/models/src/activitypub/objects/video-object.ts +++ b/packages/models/src/activitypub/objects/video-object.ts @@ -50,6 +50,7 @@ export interface VideoObject { dislikes: string shares: string comments: string + hasParts: string attributedTo: ActivityPubAttributedTo[] diff --git a/packages/models/src/videos/chapter/chapter-update.model.ts b/packages/models/src/videos/chapter/chapter-update.model.ts new file mode 100644 index 000000000..82b2091af --- /dev/null +++ b/packages/models/src/videos/chapter/chapter-update.model.ts @@ -0,0 +1,6 @@ +export interface VideoChapterUpdate { + chapters: { + timecode: number + title: string + }[] +} diff --git a/packages/models/src/videos/chapter/chapter.model.ts b/packages/models/src/videos/chapter/chapter.model.ts new file mode 100644 index 000000000..7ecba61bc --- /dev/null +++ b/packages/models/src/videos/chapter/chapter.model.ts @@ -0,0 +1,4 @@ +export interface VideoChapter { + timecode: number + title: string +} diff --git a/packages/models/src/videos/chapter/index.ts b/packages/models/src/videos/chapter/index.ts new file mode 100644 index 000000000..15fca476f --- /dev/null +++ b/packages/models/src/videos/chapter/index.ts @@ -0,0 +1,2 @@ +export * from './chapter-update.model.js' +export * from './chapter.model.js' diff --git a/packages/models/src/videos/index.ts b/packages/models/src/videos/index.ts index d131212c9..7d96d31a6 100644 --- a/packages/models/src/videos/index.ts +++ b/packages/models/src/videos/index.ts @@ -12,6 +12,7 @@ export * from './rate/index.js' export * from './stats/index.js' export * from './transcoding/index.js' export * from './channel-sync/index.js' +export * from './chapter/index.js' export * from './nsfw-policy.type.js' diff --git a/packages/server-commands/src/server/server.ts b/packages/server-commands/src/server/server.ts index 57a897c17..3911a6fad 100644 --- a/packages/server-commands/src/server/server.ts +++ b/packages/server-commands/src/server/server.ts @@ -30,6 +30,7 @@ import { ChangeOwnershipCommand, ChannelsCommand, ChannelSyncsCommand, + ChaptersCommand, CommentsCommand, HistoryCommand, ImportsCommand, @@ -152,6 +153,7 @@ export class PeerTubeServer { videoPasswords?: VideoPasswordsCommand storyboard?: StoryboardCommand + chapters?: ChaptersCommand runners?: RunnersCommand runnerRegistrationTokens?: RunnerRegistrationTokensCommand @@ -442,6 +444,7 @@ export class PeerTubeServer { this.registrations = new RegistrationsCommand(this) this.storyboard = new StoryboardCommand(this) + this.chapters = new ChaptersCommand(this) this.runners = new RunnersCommand(this) this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this) diff --git a/packages/server-commands/src/videos/chapters-command.ts b/packages/server-commands/src/videos/chapters-command.ts new file mode 100644 index 000000000..8a75c7fae --- /dev/null +++ b/packages/server-commands/src/videos/chapters-command.ts @@ -0,0 +1,38 @@ +import { + HttpStatusCode, VideoChapterUpdate, VideoChapters +} from '@peertube/peertube-models' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ChaptersCommand extends AbstractCommand { + + list (options: OverrideCommandOptions & { + videoId: string | number + }) { + const path = '/api/v1/videos/' + options.videoId + '/chapters' + + return this.getRequestBody({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + update (options: OverrideCommandOptions & VideoChapterUpdate & { + videoId: number | string + }) { + const path = '/api/v1/videos/' + options.videoId + '/chapters' + + return this.putBodyRequest({ + ...options, + + path, + fields: { + chapters: options.chapters + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/index.ts b/packages/server-commands/src/videos/index.ts index 970026d51..8d193e24c 100644 --- a/packages/server-commands/src/videos/index.ts +++ b/packages/server-commands/src/videos/index.ts @@ -3,6 +3,7 @@ export * from './captions-command.js' export * from './change-ownership-command.js' export * from './channels.js' export * from './channels-command.js' +export * from './chapters-command.js' export * from './channel-syncs-command.js' export * from './comments-command.js' export * from './history-command.js' diff --git a/packages/tests/fixtures/video_chapters.mp4 b/packages/tests/fixtures/video_chapters.mp4 new file mode 100644 index 000000000..46cbaf624 Binary files /dev/null and b/packages/tests/fixtures/video_chapters.mp4 differ diff --git a/packages/tests/src/api/check-params/index.ts b/packages/tests/src/api/check-params/index.ts index ed5fe6b06..d7867e8a5 100644 --- a/packages/tests/src/api/check-params/index.ts +++ b/packages/tests/src/api/check-params/index.ts @@ -30,6 +30,7 @@ import './video-blacklist.js' import './video-captions.js' import './video-channel-syncs.js' import './video-channels.js' +import './video-chapters.js' import './video-comments.js' import './video-files.js' import './video-imports.js' diff --git a/packages/tests/src/api/check-params/video-captions.ts b/packages/tests/src/api/check-params/video-captions.ts index 4150b095f..ac4e85068 100644 --- a/packages/tests/src/api/check-params/video-captions.ts +++ b/packages/tests/src/api/check-params/video-captions.ts @@ -31,15 +31,7 @@ describe('Test video captions API validator', function () { video = await server.videos.upload() privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } }) - - { - const user = { - username: 'user1', - password: 'my super password' - } - await server.users.create({ username: user.username, password: user.password }) - userAccessToken = await server.login.getAccessToken(user) - } + userAccessToken = await server.users.generateUserAndToken('user1') }) describe('When adding video caption', function () { @@ -120,6 +112,19 @@ describe('Test video captions API validator', function () { }) }) + it('Should fail with another user token', async function () { + const captionPath = path + video.uuid + '/captions/fr' + await makeUploadRequest({ + method: 'PUT', + url: server.url, + path: captionPath, + token: userAccessToken, + fields, + attaches, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + // We accept any file now // it('Should fail with an invalid captionfile extension', async function () { // const attaches = { diff --git a/packages/tests/src/api/check-params/video-chapters.ts b/packages/tests/src/api/check-params/video-chapters.ts new file mode 100644 index 000000000..c59f88e79 --- /dev/null +++ b/packages/tests/src/api/check-params/video-chapters.ts @@ -0,0 +1,172 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode, Video, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { + PeerTubeServer, + cleanupTests, + createSingleServer, + setAccessTokensToServers, + setDefaultVideoChannel +} from '@peertube/peertube-server-commands' + +describe('Test videos chapters API validator', function () { + let server: PeerTubeServer + let video: VideoCreateResult + let live: Video + let privateVideo: VideoCreateResult + let userAccessToken: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1) + + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + video = await server.videos.upload() + privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } }) + userAccessToken = await server.users.generateUserAndToken('user1') + + await server.config.enableLive({ allowReplay: false }) + + const res = await server.live.quickCreate({ saveReplay: false, permanentLive: false }) + live = res.video + }) + + describe('When updating chapters', function () { + + it('Should fail without a valid uuid', async function () { + await server.chapters.update({ videoId: '4da6fd', chapters: [], expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown id', async function () { + await server.chapters.update({ + videoId: 'ce0801ef-7124-48df-9b22-b473ace78797', + chapters: [], + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail without access token', async function () { + await server.chapters.update({ + videoId: video.id, + chapters: [], + token: null, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a bad access token', async function () { + await server.chapters.update({ + videoId: video.id, + chapters: [], + token: 'toto', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a another user access token', async function () { + await server.chapters.update({ + videoId: video.id, + chapters: [], + token: userAccessToken, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with a wrong chapters param', async function () { + await server.chapters.update({ + videoId: video.id, + chapters: 'hello' as any, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a bad chapter title', async function () { + await server.chapters.update({ + videoId: video.id, + chapters: [ { title: 'hello', timecode: 21 }, { title: '', timecode: 21 } ], + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await server.chapters.update({ + videoId: video.id, + chapters: [ { title: 'hello', timecode: 21 }, { title: 'a'.repeat(150), timecode: 21 } ], + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with a bad timecode', async function () { + await server.chapters.update({ + videoId: video.id, + chapters: [ { title: 'hello', timecode: 21 }, { title: 'title', timecode: -5 } ], + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await server.chapters.update({ + videoId: video.id, + chapters: [ { title: 'hello', timecode: 21 }, { title: 'title', timecode: 'hi' as any } ], + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail with non unique timecodes', async function () { + await server.chapters.update({ + videoId: video.id, + chapters: [ { title: 'hello', timecode: 21 }, { title: 'title', timecode: 22 }, { title: 'hello', timecode: 21 } ], + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail to create chapters on a live', async function () { + await server.chapters.update({ + videoId: live.id, + chapters: [], + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct params', async function () { + await server.chapters.update({ + videoId: video.id, + chapters: [] + }) + + await server.chapters.update({ + videoId: video.id, + chapters: [ { title: 'hello', timecode: 21 }, { title: 'hello 2', timecode: 35 } ] + }) + }) + }) + + describe('When listing chapters', function () { + + it('Should fail without a valid uuid', async function () { + await server.chapters.list({ videoId: '4da6fd', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should fail with an unknown id', async function () { + await server.chapters.list({ videoId: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) + }) + + it('Should not list private chapters to anyone', async function () { + await server.chapters.list({ videoId: privateVideo.uuid, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should not list private chapters to another user', async function () { + await server.chapters.list({ videoId: privateVideo.uuid, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) + }) + + it('Should list chapters', async function () { + await server.chapters.list({ videoId: privateVideo.uuid }) + await server.chapters.list({ videoId: video.uuid }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/videos/index.ts b/packages/tests/src/api/videos/index.ts index fcb1d5a81..a4bcd9741 100644 --- a/packages/tests/src/api/videos/index.ts +++ b/packages/tests/src/api/videos/index.ts @@ -4,6 +4,7 @@ import './single-server.js' import './video-captions.js' import './video-change-ownership.js' import './video-channels.js' +import './video-chapters.js' import './channel-import-videos.js' import './video-channel-syncs.js' import './video-comments.js' diff --git a/packages/tests/src/api/videos/video-chapters.ts b/packages/tests/src/api/videos/video-chapters.ts new file mode 100644 index 000000000..2f3dbcd2e --- /dev/null +++ b/packages/tests/src/api/videos/video-chapters.ts @@ -0,0 +1,342 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { VideoChapter, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' +import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils' +import { + cleanupTests, + createMultipleServers, + doubleFollow, PeerTubeServer, setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { FIXTURE_URLS } from '@tests/shared/tests.js' +import { expect } from 'chai' + +describe('Test video chapters', function () { + let servers: PeerTubeServer[] + + before(async function () { + this.timeout(120000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + + await doubleFollow(servers[0], servers[1]) + }) + + describe('Common tests', function () { + let video: VideoCreateResult + + before(async function () { + this.timeout(120000) + + video = await servers[0].videos.quickUpload({ name: 'video' }) + await waitJobs(servers) + }) + + it('Should not have chapters', async function () { + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + expect(chapters).to.deep.equal([]) + } + }) + + it('Should set chaptets', async function () { + await servers[0].chapters.update({ + videoId: video.uuid, + chapters: [ + { title: 'chapter 1', timecode: 45 }, + { title: 'chapter 2', timecode: 58 } + ] + }) + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + expect(chapters).to.deep.equal([ + { title: 'chapter 1', timecode: 45 }, + { title: 'chapter 2', timecode: 58 } + ]) + } + }) + + it('Should add new chapters', async function () { + await servers[0].chapters.update({ + videoId: video.uuid, + chapters: [ + { title: 'chapter 1', timecode: 45 }, + { title: 'chapter 2', timecode: 46 }, + { title: 'chapter 3', timecode: 58 } + ] + }) + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + expect(chapters).to.deep.equal([ + { title: 'chapter 1', timecode: 45 }, + { title: 'chapter 2', timecode: 46 }, + { title: 'chapter 3', timecode: 58 } + ]) + } + }) + + it('Should delete all chapters', async function () { + await servers[0].chapters.update({ videoId: video.uuid, chapters: [] }) + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + expect(chapters).to.deep.equal([]) + } + }) + }) + + describe('With chapters in description', function () { + const description = 'this is a super description\n' + + '00:00 chapter 1\n' + + '00:03 chapter 2\n' + + '00:04 chapter 3\n' + + function checkChapters (chapters: VideoChapter[]) { + expect(chapters).to.deep.equal([ + { + timecode: 0, + title: 'chapter 1' + }, + { + timecode: 3, + title: 'chapter 2' + }, + { + timecode: 4, + title: 'chapter 3' + } + ]) + } + + it('Should upload a video with chapters in description', async function () { + const video = await servers[0].videos.upload({ attributes: { name: 'description', description } }) + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + checkChapters(chapters) + } + }) + + it('Should update a video description and automatically add chapters', async function () { + const video = await servers[0].videos.quickUpload({ name: 'update description' }) + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + expect(chapters).to.deep.equal([]) + } + + await servers[0].videos.update({ id: video.uuid, attributes: { description } }) + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + checkChapters(chapters) + } + }) + + it('Should update a video description but not automatically add chapters since the video already has chapters', async function () { + const video = await servers[0].videos.quickUpload({ name: 'update description' }) + + await servers[0].chapters.update({ videoId: video.uuid, chapters: [ { timecode: 5, title: 'chapter 1' } ] }) + await servers[0].videos.update({ id: video.uuid, attributes: { description } }) + + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + expect(chapters).to.deep.equal([ { timecode: 5, title: 'chapter 1' } ]) + } + }) + + it('Should update multiple times chapters from description', async function () { + const video = await servers[0].videos.quickUpload({ name: 'update description' }) + + await servers[0].videos.update({ id: video.uuid, attributes: { description } }) + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + checkChapters(chapters) + } + + await servers[0].videos.update({ id: video.uuid, attributes: { description: '00:01 chapter 1' } }) + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + expect(chapters).to.deep.equal([ { timecode: 1, title: 'chapter 1' } ]) + } + + await servers[0].videos.update({ id: video.uuid, attributes: { description: 'null description' } }) + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + expect(chapters).to.deep.equal([]) + } + }) + }) + + describe('With upload', function () { + + it('Should upload a mp4 containing chapters and automatically add them', async function () { + const video = await servers[0].videos.quickUpload({ fixture: 'video_chapters.mp4', name: 'chapters' }) + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + expect(chapters).to.deep.equal([ + { + timecode: 0, + title: 'Chapter 1' + }, + { + timecode: 2, + title: 'Chapter 2' + }, + { + timecode: 4, + title: 'Chapter 3' + } + ]) + } + }) + }) + + describe('With URL import', function () { + if (areHttpImportTestsDisabled()) return + + it('Should detect chapters from youtube URL import', async function () { + this.timeout(120000) + + const attributes = { + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + targetUrl: FIXTURE_URLS.youtubeChapters, + description: 'this is a super description\n' + } + const { video } = await servers[0].imports.importVideo({ attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + expect(chapters).to.deep.equal([ + { + timecode: 0, + title: 'chapter 1' + }, + { + timecode: 15, + title: 'chapter 2' + }, + { + timecode: 35, + title: 'chapter 3' + }, + { + timecode: 40, + title: 'chapter 4' + } + ]) + } + }) + + it('Should have overriden description priority from youtube URL import', async function () { + this.timeout(120000) + + const attributes = { + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + targetUrl: FIXTURE_URLS.youtubeChapters, + description: 'this is a super description\n' + + '00:00 chapter 1\n' + + '00:03 chapter 2\n' + + '00:04 chapter 3\n' + } + const { video } = await servers[0].imports.importVideo({ attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + expect(chapters).to.deep.equal([ + { + timecode: 0, + title: 'chapter 1' + }, + { + timecode: 3, + title: 'chapter 2' + }, + { + timecode: 4, + title: 'chapter 3' + } + ]) + } + }) + + it('Should detect chapters from raw URL import', async function () { + this.timeout(120000) + + const attributes = { + channelId: servers[0].store.channel.id, + privacy: VideoPrivacy.PUBLIC, + targetUrl: FIXTURE_URLS.chatersVideo + } + const { video } = await servers[0].imports.importVideo({ attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { chapters } = await server.chapters.list({ videoId: video.uuid }) + + expect(chapters).to.deep.equal([ + { + timecode: 0, + title: 'Chapter 1' + }, + { + timecode: 2, + title: 'Chapter 2' + }, + { + timecode: 4, + title: 'Chapter 3' + } + ]) + } + }) + }) + + // TODO: test torrent import too + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/server-helpers/core-utils.ts b/packages/tests/src/server-helpers/core-utils.ts index d61cae855..0df238e88 100644 --- a/packages/tests/src/server-helpers/core-utils.ts +++ b/packages/tests/src/server-helpers/core-utils.ts @@ -3,7 +3,7 @@ import { expect } from 'chai' import snakeCase from 'lodash-es/snakeCase.js' import validator from 'validator' -import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate } from '@peertube/peertube-core-utils' +import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, parseChapters } from '@peertube/peertube-core-utils' import { VideoResolution } from '@peertube/peertube-models' import { objectConverter, parseBytes, parseDurationToMs, parseSemVersion } from '@peertube/peertube-server/server/helpers/core-utils.js' @@ -199,3 +199,28 @@ describe('Parse semantic version string', function () { expect(actual.patch).to.equal(0) }) }) + +describe('Extract chapters', function () { + + it('Should not extract chapters', function () { + expect(parseChapters('my super description\nno?')).to.deep.equal([]) + expect(parseChapters('m00:00 super description\nno?')).to.deep.equal([]) + expect(parseChapters('00:00super description\nno?')).to.deep.equal([]) + }) + + it('Should extract chapters', function () { + expect(parseChapters('00:00 coucou')).to.deep.equal([ { timecode: 0, title: 'coucou' } ]) + expect(parseChapters('my super description\n\n00:01:30 chapter 1\n00:01:35 chapter 2')).to.deep.equal([ + { timecode: 90, title: 'chapter 1' }, + { timecode: 95, title: 'chapter 2' } + ]) + expect(parseChapters('hi\n\n00:01:30 chapter 1\n00:01:35 chapter 2\nhi')).to.deep.equal([ + { timecode: 90, title: 'chapter 1' }, + { timecode: 95, title: 'chapter 2' } + ]) + expect(parseChapters('hi\n\n00:01:30 chapter 1\n00:01:35 chapter 2\nhi\n00:01:40 chapter 3')).to.deep.equal([ + { timecode: 90, title: 'chapter 1' }, + { timecode: 95, title: 'chapter 2' } + ]) + }) +}) diff --git a/packages/tests/src/shared/tests.ts b/packages/tests/src/shared/tests.ts index d2cb040fb..554ed0e1f 100644 --- a/packages/tests/src/shared/tests.ts +++ b/packages/tests/src/shared/tests.ts @@ -3,6 +3,7 @@ const FIXTURE_URLS = { peertube_short: 'https://peertube2.cpy.re/w/3fbif9S3WmtTP8gGsC5HBd', youtube: 'https://www.youtube.com/watch?v=msX3jv1XdvM', + youtubeChapters: 'https://www.youtube.com/watch?v=TL9P-Er7ils', /** * The video is used to check format-selection correctness wrt. HDR, @@ -26,6 +27,8 @@ const FIXTURE_URLS = { goodVideo: 'https://download.cpy.re/peertube/good_video.mp4', goodVideo720: 'https://download.cpy.re/peertube/good_video_720.mp4', + chatersVideo: 'https://download.cpy.re/peertube/video_chapters.mp4', + file4K: 'https://download.cpy.re/peertube/4k_file.txt' } diff --git a/packages/typescript-utils/src/types.ts b/packages/typescript-utils/src/types.ts index 57cc23f1f..cd998a467 100644 --- a/packages/typescript-utils/src/types.ts +++ b/packages/typescript-utils/src/types.ts @@ -43,3 +43,5 @@ export type DeepOmit = T extends Primitive ? T : DeepOmitHelper = { [P in keyof T]: DeepOmit } + +export type Unpacked = T extends (infer U)[] ? U : T diff --git a/server/server/controllers/activitypub/client.ts b/server/server/controllers/activitypub/client.ts index 5d5e43bf5..1d5d269a9 100644 --- a/server/server/controllers/activitypub/client.ts +++ b/server/server/controllers/activitypub/client.ts @@ -1,6 +1,13 @@ import cors from 'cors' import express from 'express' -import { VideoCommentObject, VideoPlaylistPrivacy, VideoPrivacy, VideoRateType } from '@peertube/peertube-models' +import { + VideoChapterObject, + VideoChaptersObject, + VideoCommentObject, + VideoPlaylistPrivacy, + VideoPrivacy, + VideoRateType +} from '@peertube/peertube-models' import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js' import { getContextFilter } from '@server/lib/activitypub/context.js' import { getServerActor } from '@server/models/application/application.js' @@ -12,12 +19,18 @@ import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/act import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js' import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js' import { + getLocalVideoChaptersActivityPubUrl, getLocalVideoCommentsActivityPubUrl, getLocalVideoDislikesActivityPubUrl, getLocalVideoLikesActivityPubUrl, getLocalVideoSharesActivityPubUrl } from '../../lib/activitypub/url.js' -import { cacheRoute } from '../../middlewares/cache/cache.js' +import { + apVideoChaptersSetCacheKey, + buildAPVideoChaptersGroupsCache, + cacheRoute, + cacheRouteFactory +} from '../../middlewares/cache/cache.js' import { activityPubRateLimiter, asyncMiddleware, @@ -42,6 +55,8 @@ import { VideoCommentModel } from '../../models/video/video-comment.js' import { VideoPlaylistModel } from '../../models/video/video-playlist.js' import { VideoShareModel } from '../../models/video/video-share.js' import { activityPubResponse } from './utils.js' +import { VideoChapterModel } from '@server/models/video/video-chapter.js' +import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js' const activityPubClientRouter = express.Router() activityPubClientRouter.use(cors()) @@ -145,6 +160,27 @@ activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity asyncMiddleware(videoCommentController) ) +// --------------------------------------------------------------------------- + +const { middleware: chaptersCacheRouteMiddleware, instance: chaptersApiCache } = cacheRouteFactory() + +InternalEventEmitter.Instance.on('chapters-updated', ({ video }) => { + if (video.remote) return + + chaptersApiCache.clearGroupSafe(buildAPVideoChaptersGroupsCache({ videoId: video.uuid })) +}) + +activityPubClientRouter.get('/videos/watch/:id/chapters', + executeIfActivityPub, + activityPubRateLimiter, + apVideoChaptersSetCacheKey, + chaptersCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS), + asyncMiddleware(videosCustomGetValidator('only-video')), + asyncMiddleware(videoChaptersController) +) + +// --------------------------------------------------------------------------- + activityPubClientRouter.get( [ '/video-channels/:nameWithHost', '/video-channels/:nameWithHost/videos', '/c/:nameWithHost', '/c/:nameWithHost/videos' ], executeIfActivityPub, @@ -390,6 +426,31 @@ async function videoCommentController (req: express.Request, res: express.Respon return activityPubResponse(activityPubContextify(videoCommentObject, 'Comment', getContextFilter()), res) } +async function videoChaptersController (req: express.Request, res: express.Response) { + const video = res.locals.onlyVideo + + if (redirectIfNotOwned(video.url, res)) return + + const chapters = await VideoChapterModel.listChaptersOfVideo(video.id) + + const hasPart: VideoChapterObject[] = [] + + if (chapters.length !== 0) { + for (let i = 0; i < chapters.length - 1; i++) { + hasPart.push(chapters[i].toActivityPubJSON({ video, nextChapter: chapters[i + 1] })) + } + + hasPart.push(chapters[chapters.length - 1].toActivityPubJSON({ video: res.locals.onlyVideo, nextChapter: null })) + } + + const chaptersObject: VideoChaptersObject = { + id: getLocalVideoChaptersActivityPubUrl(video), + hasPart + } + + return activityPubResponse(activityPubContextify(chaptersObject, 'Chapters', getContextFilter()), res) +} + async function videoRedundancyController (req: express.Request, res: express.Response) { const videoRedundancy = res.locals.videoRedundancy diff --git a/server/server/controllers/api/videos/chapters.ts b/server/server/controllers/api/videos/chapters.ts new file mode 100644 index 000000000..f744a2b56 --- /dev/null +++ b/server/server/controllers/api/videos/chapters.ts @@ -0,0 +1,51 @@ +import express from 'express' +import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares/index.js' +import { updateVideoChaptersValidator, videosCustomGetValidator } from '../../../middlewares/validators/index.js' +import { VideoChapterModel } from '@server/models/video/video-chapter.js' +import { HttpStatusCode, VideoChapterUpdate } from '@peertube/peertube-models' +import { sequelizeTypescript } from '@server/initializers/database.js' +import { retryTransactionWrapper } from '@server/helpers/database-utils.js' +import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js' +import { replaceChapters } from '@server/lib/video-chapters.js' + +const videoChaptersRouter = express.Router() + +videoChaptersRouter.get('/:id/chapters', + asyncMiddleware(videosCustomGetValidator('only-video')), + asyncMiddleware(listVideoChapters) +) + +videoChaptersRouter.put('/:videoId/chapters', + authenticate, + asyncMiddleware(updateVideoChaptersValidator), + asyncRetryTransactionMiddleware(replaceVideoChapters) +) + +// --------------------------------------------------------------------------- + +export { + videoChaptersRouter +} + +// --------------------------------------------------------------------------- + +async function listVideoChapters (req: express.Request, res: express.Response) { + const chapters = await VideoChapterModel.listChaptersOfVideo(res.locals.onlyVideo.id) + + return res.json({ chapters: chapters.map(c => c.toFormattedJSON()) }) +} + +async function replaceVideoChapters (req: express.Request, res: express.Response) { + const body = req.body as VideoChapterUpdate + const video = res.locals.videoAll + + await retryTransactionWrapper(() => { + return sequelizeTypescript.transaction(async t => { + await replaceChapters({ video, chapters: body.chapters, transaction: t }) + + await federateVideoIfNeeded(video, false, t) + }) + }) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} diff --git a/server/server/controllers/api/videos/index.ts b/server/server/controllers/api/videos/index.ts index f8e3d9cb5..508cbb7c5 100644 --- a/server/server/controllers/api/videos/index.ts +++ b/server/server/controllers/api/videos/index.ts @@ -49,6 +49,7 @@ import { transcodingRouter } from './transcoding.js' import { updateRouter } from './update.js' import { uploadRouter } from './upload.js' import { viewRouter } from './view.js' +import { videoChaptersRouter } from './chapters.js' const auditLogger = auditLoggerFactory('videos') const videosRouter = express.Router() @@ -73,6 +74,7 @@ videosRouter.use('/', tokenRouter) videosRouter.use('/', videoPasswordRouter) videosRouter.use('/', storyboardRouter) videosRouter.use('/', videoSourceRouter) +videosRouter.use('/', videoChaptersRouter) videosRouter.get('/categories', openapiOperationDoc({ operationId: 'getCategories' }), diff --git a/server/server/controllers/api/videos/update.ts b/server/server/controllers/api/videos/update.ts index 491175d74..5adc5e8e5 100644 --- a/server/server/controllers/api/videos/update.ts +++ b/server/server/controllers/api/videos/update.ts @@ -22,6 +22,7 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist.js' import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js' import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' import { VideoModel } from '../../../models/video/video.js' +import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js' const lTags = loggerTagsFactory('api', 'video') const auditLogger = auditLoggerFactory('videos') @@ -67,6 +68,7 @@ async function updateVideo (req: express.Request, res: express.Response) { // Refresh video since thumbnails to prevent concurrent updates const video = await VideoModel.loadFull(videoFromReq.id, t) + const oldDescription = video.description const oldVideoChannel = video.VideoChannel const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes)[] = [ @@ -127,6 +129,15 @@ async function updateVideo (req: express.Request, res: express.Response) { // Schedule an update in the future? await updateSchedule(videoInstanceUpdated, videoInfoToUpdate, t) + if (oldDescription !== video.description) { + await replaceChaptersFromDescriptionIfNeeded({ + newDescription: videoInstanceUpdated.description, + transaction: t, + video, + oldDescription + }) + } + await autoBlacklistVideoIfNeeded({ video: videoInstanceUpdated, user: res.locals.oauth.token.User, diff --git a/server/server/controllers/api/videos/upload.ts b/server/server/controllers/api/videos/upload.ts index 47f06e336..3d87deb1b 100644 --- a/server/server/controllers/api/videos/upload.ts +++ b/server/server/controllers/api/videos/upload.ts @@ -34,6 +34,8 @@ import { } from '../../../middlewares/index.js' import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' import { VideoModel } from '../../../models/video/video.js' +import { getChaptersFromContainer } from '@peertube/peertube-ffmpeg' +import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js' const lTags = loggerTagsFactory('api', 'video') const auditLogger = auditLoggerFactory('videos') @@ -143,6 +145,9 @@ async function addVideo (options: { const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' }) const originalFilename = videoPhysicalFile.originalname + const containerChapters = await getChaptersFromContainer(videoPhysicalFile.path) + logger.debug(`Got ${containerChapters.length} chapters from video "${video.name}" container`, { containerChapters, ...lTags(video.uuid) }) + // Move physical file const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile) await move(videoPhysicalFile.path, destination) @@ -188,6 +193,10 @@ async function addVideo (options: { }, sequelizeOptions) } + if (!await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction: t })) { + await replaceChapters({ video, chapters: containerChapters, transaction: t }) + } + await autoBlacklistVideoIfNeeded({ video, user, diff --git a/server/server/helpers/activity-pub-utils.ts b/server/server/helpers/activity-pub-utils.ts index acc5c304b..cda40fdaa 100644 --- a/server/server/helpers/activity-pub-utils.ts +++ b/server/server/helpers/activity-pub-utils.ts @@ -79,6 +79,8 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string uploadDate: 'sc:uploadDate', + hasParts: 'sc:hasParts', + views: { '@type': 'sc:Number', '@id': 'pt:views' @@ -195,7 +197,14 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string Announce: buildContext(), Comment: buildContext(), Delete: buildContext(), - Rate: buildContext() + Rate: buildContext(), + + Chapters: buildContext({ + name: 'sc:name', + hasPart: 'sc:hasPart', + endOffset: 'sc:endOffset', + startOffset: 'sc:startOffset' + }) } async function getContextData (type: ContextType, contextFilter: ContextFilter) { diff --git a/server/server/helpers/custom-validators/activitypub/video-chapters.ts b/server/server/helpers/custom-validators/activitypub/video-chapters.ts new file mode 100644 index 000000000..38009991b --- /dev/null +++ b/server/server/helpers/custom-validators/activitypub/video-chapters.ts @@ -0,0 +1,15 @@ +import { isArray } from '../misc.js' +import { isVideoChapterTitleValid, isVideoChapterTimecodeValid } from '../video-chapters.js' +import { isActivityPubUrlValid } from './misc.js' +import { VideoChaptersObject } from '@peertube/peertube-models' + +export function isVideoChaptersObjectValid (object: VideoChaptersObject) { + if (!object) return false + if (!isActivityPubUrlValid(object.id)) return false + + if (!isArray(object.hasPart)) return false + + return object.hasPart.every(part => { + return isVideoChapterTitleValid(part.name) && isVideoChapterTimecodeValid(part.startOffset) + }) +} diff --git a/server/server/helpers/custom-validators/video-chapters.ts b/server/server/helpers/custom-validators/video-chapters.ts new file mode 100644 index 000000000..8bdd2d7b8 --- /dev/null +++ b/server/server/helpers/custom-validators/video-chapters.ts @@ -0,0 +1,26 @@ +import { isArray } from './misc.js' +import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models' +import { Unpacked } from '@peertube/peertube-typescript-utils' +import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js' +import validator from 'validator' + +export function areVideoChaptersValid (value: VideoChapter[]) { + if (!isArray(value)) return false + if (!value.every(v => isVideoChapterValid(v))) return false + + const timecodes = value.map(c => c.timecode) + + return new Set(timecodes).size === timecodes.length +} + +export function isVideoChapterValid (value: Unpacked) { + return isVideoChapterTimecodeValid(value.timecode) && isVideoChapterTitleValid(value.title) +} + +export function isVideoChapterTitleValid (value: any) { + return validator.default.isLength(value + '', CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE) +} + +export function isVideoChapterTimecodeValid (value: any) { + return validator.default.isInt(value + '', { min: 0 }) +} diff --git a/server/server/helpers/youtube-dl/youtube-dl-info-builder.ts b/server/server/helpers/youtube-dl/youtube-dl-info-builder.ts index 0287f6183..66993d2ee 100644 --- a/server/server/helpers/youtube-dl/youtube-dl-info-builder.ts +++ b/server/server/helpers/youtube-dl/youtube-dl-info-builder.ts @@ -1,6 +1,7 @@ import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js' import { peertubeTruncate } from '../core-utils.js' import { isUrlValid } from '../custom-validators/activitypub/misc.js' +import { isArray } from '../custom-validators/misc.js' export type YoutubeDLInfo = { name?: string @@ -16,6 +17,11 @@ export type YoutubeDLInfo = { webpageUrl?: string urls?: string[] + + chapters?: { + timecode: number + title: string + }[] } export class YoutubeDLInfoBuilder { @@ -83,7 +89,10 @@ export class YoutubeDLInfoBuilder { urls: this.buildAvailableUrl(obj), originallyPublishedAtWithoutTime: this.buildOriginallyPublishedAt(obj), ext: obj.ext, - webpageUrl: obj.webpage_url + webpageUrl: obj.webpage_url, + chapters: isArray(obj.chapters) + ? obj.chapters.map((c: { start_time: number, title: string }) => ({ timecode: c.start_time, title: c.title })) + : [] } } diff --git a/server/server/initializers/constants.ts b/server/server/initializers/constants.ts index 34392dbc8..027b927c2 100644 --- a/server/server/initializers/constants.ts +++ b/server/server/initializers/constants.ts @@ -465,6 +465,9 @@ const CONSTRAINTS_FIELDS = { }, VIDEO_PASSWORD: { LENGTH: { min: 2, max: 100 } + }, + VIDEO_CHAPTERS: { + TITLE: { min: 1, max: 100 } // Length } } diff --git a/server/server/initializers/database.ts b/server/server/initializers/database.ts index fe399a633..0294e2d29 100644 --- a/server/server/initializers/database.ts +++ b/server/server/initializers/database.ts @@ -59,6 +59,7 @@ import { VideoTagModel } from '../models/video/video-tag.js' import { VideoModel } from '../models/video/video.js' import { VideoViewModel } from '../models/view/video-view.js' import { CONFIG } from './config.js' +import { VideoChapterModel } from '@server/models/video/video-chapter.js' pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string @@ -137,6 +138,7 @@ async function initDatabaseModels (silent: boolean) { VideoShareModel, VideoFileModel, VideoSourceModel, + VideoChapterModel, VideoCaptionModel, VideoBlacklistModel, VideoTagModel, diff --git a/server/server/lib/activitypub/url.ts b/server/server/lib/activitypub/url.ts index 73f6f4849..aff104804 100644 --- a/server/server/lib/activitypub/url.ts +++ b/server/server/lib/activitypub/url.ts @@ -80,6 +80,10 @@ function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) { return video.url + '/comments' } +function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) { + return video.url + '/chapters' +} + function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) { return video.url + '/likes' } @@ -167,6 +171,7 @@ export { getDeleteActivityPubUrl, getLocalVideoSharesActivityPubUrl, getLocalVideoCommentsActivityPubUrl, + getLocalVideoChaptersActivityPubUrl, getLocalVideoLikesActivityPubUrl, getLocalVideoDislikesActivityPubUrl, getLocalVideoViewerActivityPubUrl, diff --git a/server/server/lib/activitypub/videos/shared/abstract-builder.ts b/server/server/lib/activitypub/videos/shared/abstract-builder.ts index 4397e578f..2c0ad99ac 100644 --- a/server/server/lib/activitypub/videos/shared/abstract-builder.ts +++ b/server/server/lib/activitypub/videos/shared/abstract-builder.ts @@ -1,6 +1,12 @@ import { CreationAttributes, Transaction } from 'sequelize' -import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType_Type } from '@peertube/peertube-models' -import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils.js' +import { + ActivityTagObject, + ThumbnailType, + VideoChaptersObject, + VideoObject, + VideoStreamingPlaylistType_Type +} from '@peertube/peertube-models' +import { deleteAllModels, filterNonExistingModels, retryTransactionWrapper } from '@server/helpers/database-utils.js' import { logger, LoggerTagsFn } from '@server/helpers/logger.js' import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js' import { setVideoTags } from '@server/lib/video.js' @@ -29,6 +35,10 @@ import { getThumbnailFromIcons } from './object-to-model-attributes.js' import { getTrackerUrls, setVideoTrackers } from './trackers.js' +import { fetchAP } from '../../activity.js' +import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js' +import { sequelizeTypescript } from '@server/initializers/database.js' +import { replaceChapters } from '@server/lib/video-chapters.js' export abstract class APVideoAbstractBuilder { protected abstract videoObject: VideoObject @@ -44,7 +54,7 @@ export abstract class APVideoAbstractBuilder { protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) { const miniatureIcon = getThumbnailFromIcons(this.videoObject) if (!miniatureIcon) { - logger.warn('Cannot find thumbnail in video object', { object: this.videoObject }) + logger.warn('Cannot find thumbnail in video object', { object: this.videoObject, ...this.lTags() }) return undefined } @@ -138,6 +148,26 @@ export abstract class APVideoAbstractBuilder { video.VideoFiles = await Promise.all(upsertTasks) } + protected async updateChaptersOutsideTransaction (video: MVideoFullLight) { + if (!this.videoObject.hasParts || typeof this.videoObject.hasParts !== 'string') return + + const { body } = await fetchAP(this.videoObject.hasParts) + if (!isVideoChaptersObjectValid(body)) { + logger.warn('Chapters AP object is not valid, skipping', { body, ...this.lTags() }) + return + } + + logger.debug('Fetched chapters AP object', { body, ...this.lTags() }) + + return retryTransactionWrapper(() => { + return sequelizeTypescript.transaction(async t => { + const chapters = body.hasPart.map(p => ({ title: p.name, timecode: p.startOffset })) + + await replaceChapters({ chapters, transaction: t, video }) + }) + }) + } + protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) { const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject) const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a)) diff --git a/server/server/lib/activitypub/videos/shared/creator.ts b/server/server/lib/activitypub/videos/shared/creator.ts index 5a3a46282..35e537ccc 100644 --- a/server/server/lib/activitypub/videos/shared/creator.ts +++ b/server/server/lib/activitypub/videos/shared/creator.ts @@ -60,6 +60,8 @@ export class APVideoCreator extends APVideoAbstractBuilder { return { autoBlacklisted, videoCreated } }) + await this.updateChaptersOutsideTransaction(videoCreated) + return { autoBlacklisted, videoCreated } } } diff --git a/server/server/lib/activitypub/videos/updater.ts b/server/server/lib/activitypub/videos/updater.ts index 37bf7411a..f9c5b4040 100644 --- a/server/server/lib/activitypub/videos/updater.ts +++ b/server/server/lib/activitypub/videos/updater.ts @@ -77,6 +77,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder { await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t)) + await this.updateChaptersOutsideTransaction(videoUpdated) + await autoBlacklistVideoIfNeeded({ video: videoUpdated, user: undefined, diff --git a/server/server/lib/internal-event-emitter.ts b/server/server/lib/internal-event-emitter.ts index 54f192982..db6e674d0 100644 --- a/server/server/lib/internal-event-emitter.ts +++ b/server/server/lib/internal-event-emitter.ts @@ -1,4 +1,4 @@ -import { MChannel, MVideo } from '@server/types/models/index.js' +import { MChannel, MVideo, MVideoImmutable } from '@server/types/models/index.js' import { EventEmitter } from 'events' export interface PeerTubeInternalEvents { @@ -9,6 +9,8 @@ export interface PeerTubeInternalEvents { 'channel-created': (options: { channel: MChannel }) => void 'channel-updated': (options: { channel: MChannel }) => void 'channel-deleted': (options: { channel: MChannel }) => void + + 'chapters-updated': (options: { video: MVideoImmutable }) => void } declare interface InternalEventEmitter { diff --git a/server/server/lib/job-queue/handlers/video-import.ts b/server/server/lib/job-queue/handlers/video-import.ts index 7d5435a3b..09d974e90 100644 --- a/server/server/lib/job-queue/handlers/video-import.ts +++ b/server/server/lib/job-queue/handlers/video-import.ts @@ -32,6 +32,7 @@ import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImpo import { getLowercaseExtension } from '@peertube/peertube-node-utils' import { ffprobePromise, + getChaptersFromContainer, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, @@ -49,6 +50,7 @@ import { federateVideoIfNeeded } from '../../activitypub/videos/index.js' import { Notifier } from '../../notifier/index.js' import { generateLocalVideoMiniature } from '../../thumbnail.js' import { JobQueue } from '../job-queue.js' +import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js' async function processVideoImport (job: Job): Promise { const payload = job.data as VideoImportPayload @@ -150,6 +152,8 @@ async function processFile (downloader: () => Promise, videoImport: MVid const fps = await getVideoStreamFPS(tempVideoPath, probe) const duration = await getVideoStreamDuration(tempVideoPath, probe) + const containerChapters = await getChaptersFromContainer(tempVideoPath, probe) + // Prepare video file object for creation in database const fileExt = getLowercaseExtension(tempVideoPath) const videoFileData = { @@ -228,6 +232,8 @@ async function processFile (downloader: () => Promise, videoImport: MVid if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t) if (previewModel) await video.addAndSaveThumbnail(previewModel, t) + await replaceChaptersIfNotExist({ video, chapters: containerChapters, transaction: t }) + // Now we can federate the video (reload from database, we need more attributes) const videoForFederation = await VideoModel.loadFull(video.uuid, t) await federateVideoIfNeeded(videoForFederation, true, t) diff --git a/server/server/lib/video-chapters.ts b/server/server/lib/video-chapters.ts new file mode 100644 index 000000000..c2b091356 --- /dev/null +++ b/server/server/lib/video-chapters.ts @@ -0,0 +1,99 @@ +import { parseChapters, sortBy } from '@peertube/peertube-core-utils' +import { VideoChapter } from '@peertube/peertube-models' +import { logger, loggerTagsFactory } from '@server/helpers/logger.js' +import { VideoChapterModel } from '@server/models/video/video-chapter.js' +import { MVideoImmutable } from '@server/types/models/index.js' +import { Transaction } from 'sequelize' +import { InternalEventEmitter } from './internal-event-emitter.js' + +const lTags = loggerTagsFactory('video', 'chapters') + +export async function replaceChapters (options: { + video: MVideoImmutable + chapters: VideoChapter[] + transaction: Transaction +}) { + const { chapters, transaction, video } = options + + await VideoChapterModel.deleteChapters(video.id, transaction) + + await createChapters({ videoId: video.id, chapters, transaction }) + + InternalEventEmitter.Instance.emit('chapters-updated', { video }) +} + +export async function replaceChaptersIfNotExist (options: { + video: MVideoImmutable + chapters: VideoChapter[] + transaction: Transaction +}) { + const { chapters, transaction, video } = options + + if (await VideoChapterModel.hasVideoChapters(video.id, transaction)) return + + await createChapters({ videoId: video.id, chapters, transaction }) + + InternalEventEmitter.Instance.emit('chapters-updated', { video }) +} + +export async function replaceChaptersFromDescriptionIfNeeded (options: { + oldDescription?: string + newDescription: string + video: MVideoImmutable + transaction: Transaction +}) { + const { transaction, video, newDescription, oldDescription = '' } = options + + const chaptersFromOldDescription = sortBy(parseChapters(oldDescription), 'timecode') + const existingChapters = await VideoChapterModel.listChaptersOfVideo(video.id, transaction) + + logger.debug( + 'Check if we replace chapters from description', + { oldDescription, chaptersFromOldDescription, newDescription, existingChapters, ...lTags(video.uuid) } + ) + + // Then we can update chapters from the new description + if (areSameChapters(chaptersFromOldDescription, existingChapters)) { + const chaptersFromNewDescription = sortBy(parseChapters(newDescription), 'timecode') + if (chaptersFromOldDescription.length === 0 && chaptersFromNewDescription.length === 0) return false + + await replaceChapters({ video, chapters: chaptersFromNewDescription, transaction }) + + logger.info('Replaced chapters of video ' + video.uuid, { chaptersFromNewDescription, ...lTags(video.uuid) }) + + return true + } + + return false +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +async function createChapters (options: { + videoId: number + chapters: VideoChapter[] + transaction: Transaction +}) { + const { chapters, transaction, videoId } = options + + for (const chapter of chapters) { + await VideoChapterModel.create({ + title: chapter.title, + timecode: chapter.timecode, + videoId + }, { transaction }) + } +} + +function areSameChapters (chapters1: VideoChapter[], chapters2: VideoChapter[]) { + if (chapters1.length !== chapters2.length) return false + + for (let i = 0; i < chapters1.length; i++) { + if (chapters1[i].timecode !== chapters2[i].timecode) return false + if (chapters1[i].title !== chapters2[i].title) return false + } + + return true +} diff --git a/server/server/lib/video-pre-import.ts b/server/server/lib/video-pre-import.ts index 0298e121e..447ea341d 100644 --- a/server/server/lib/video-pre-import.ts +++ b/server/server/lib/video-pre-import.ts @@ -39,6 +39,7 @@ import { } from '@server/types/models/index.js' import { getLocalVideoActivityPubUrl } from './activitypub/url.js' import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js' +import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js' class YoutubeDlImportError extends Error { code: YoutubeDlImportError.CODE @@ -227,6 +228,29 @@ async function buildYoutubeDLImport (options: { videoPasswords: importDataOverride.videoPasswords }) + await sequelizeTypescript.transaction(async transaction => { + // Priority to explicitely set description + if (importDataOverride?.description) { + const inserted = await replaceChaptersFromDescriptionIfNeeded({ newDescription: importDataOverride.description, video, transaction }) + if (inserted) return + } + + // Then priority to youtube-dl chapters + if (youtubeDLInfo.chapters.length !== 0) { + logger.info( + `Inserting chapters in video ${video.uuid} from youtube-dl`, + { chapters: youtubeDLInfo.chapters, tags: [ 'chapters', video.uuid ] } + ) + + await replaceChapters({ video, chapters: youtubeDLInfo.chapters, transaction }) + return + } + + if (video.description) { + await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction }) + } + }) + // Get video subtitles await processYoutubeSubtitles(youtubeDL, targetUrl, video.id) diff --git a/server/server/middlewares/cache/cache.ts b/server/server/middlewares/cache/cache.ts index 6cf37e322..e615fc353 100644 --- a/server/server/middlewares/cache/cache.ts +++ b/server/server/middlewares/cache/cache.ts @@ -1,3 +1,4 @@ +import express from 'express' import { HttpStatusCode } from '@peertube/peertube-models' import { ApiCache, APICacheOptions } from './shared/index.js' @@ -8,13 +9,13 @@ const defaultOptions: APICacheOptions = { ] } -function cacheRoute (duration: string) { +export function cacheRoute (duration: string) { const instance = new ApiCache(defaultOptions) return instance.buildMiddleware(duration) } -function cacheRouteFactory (options: APICacheOptions) { +export function cacheRouteFactory (options: APICacheOptions = {}) { const instance = new ApiCache({ ...defaultOptions, ...options }) return { instance, middleware: instance.buildMiddleware.bind(instance) } @@ -22,17 +23,36 @@ function cacheRouteFactory (options: APICacheOptions) { // --------------------------------------------------------------------------- -function buildPodcastGroupsCache (options: { +export function buildPodcastGroupsCache (options: { channelId: number }) { return 'podcast-feed-' + options.channelId } +export function buildAPVideoChaptersGroupsCache (options: { + videoId: number | string +}) { + return 'ap-video-chapters-' + options.videoId +} + // --------------------------------------------------------------------------- -export { - cacheRoute, - cacheRouteFactory, +export const videoFeedsPodcastSetCacheKey = [ + (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (req.query.videoChannelId) { + res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ] + } - buildPodcastGroupsCache -} + return next() + } +] + +export const apVideoChaptersSetCacheKey = [ + (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (req.params.id) { + res.locals.apicacheGroups = [ buildAPVideoChaptersGroupsCache({ videoId: req.params.id }) ] + } + + return next() + } +] diff --git a/server/server/middlewares/validators/feeds.ts b/server/server/middlewares/validators/feeds.ts index ec99b6920..895dd35ba 100644 --- a/server/server/middlewares/validators/feeds.ts +++ b/server/server/middlewares/validators/feeds.ts @@ -3,7 +3,6 @@ import { param, query } from 'express-validator' import { HttpStatusCode } from '@peertube/peertube-models' import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js' import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js' -import { buildPodcastGroupsCache } from '../cache/index.js' import { areValidationErrors, checkCanSeeVideo, @@ -114,15 +113,6 @@ const videoFeedsPodcastValidator = [ } ] -const videoFeedsPodcastSetCacheKey = [ - (req: express.Request, res: express.Response, next: express.NextFunction) => { - if (req.query.videoChannelId) { - res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ] - } - - return next() - } -] // --------------------------------------------------------------------------- const videoSubscriptionFeedsValidator = [ @@ -173,6 +163,5 @@ export { feedsAccountOrChannelFiltersValidator, videoFeedsPodcastValidator, videoSubscriptionFeedsValidator, - videoFeedsPodcastSetCacheKey, videoCommentsFeedsValidator } diff --git a/server/server/middlewares/validators/videos/index.ts b/server/server/middlewares/validators/videos/index.ts index 05c6659ae..eed4f35d4 100644 --- a/server/server/middlewares/validators/videos/index.ts +++ b/server/server/middlewares/validators/videos/index.ts @@ -2,6 +2,7 @@ export * from './video-blacklist.js' export * from './video-captions.js' export * from './video-channel-sync.js' export * from './video-channels.js' +export * from './video-chapters.js' export * from './video-comments.js' export * from './video-files.js' export * from './video-imports.js' diff --git a/server/server/middlewares/validators/videos/video-chapters.ts b/server/server/middlewares/validators/videos/video-chapters.ts new file mode 100644 index 000000000..5097e6380 --- /dev/null +++ b/server/server/middlewares/validators/videos/video-chapters.ts @@ -0,0 +1,34 @@ +import express from 'express' +import { body } from 'express-validator' +import { HttpStatusCode, UserRight } from '@peertube/peertube-models' +import { + areValidationErrors, checkUserCanManageVideo, doesVideoExist, + isValidVideoIdParam +} from '../shared/index.js' +import { areVideoChaptersValid } from '@server/helpers/custom-validators/video-chapters.js' + +export const updateVideoChaptersValidator = [ + isValidVideoIdParam('videoId'), + + body('chapters') + .custom(areVideoChaptersValid) + .withMessage('Chapters must have a valid title and timecode, and each timecode must be unique'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + if (!await doesVideoExist(req.params.videoId, res)) return + + if (res.locals.videoAll.isLive) { + return res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message: 'You cannot add chapters to a live video' + }) + } + + // Check if the user who did the request is able to update video chapters (same right as updating the video) + const user = res.locals.oauth.token.User + if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return + + return next() + } +] diff --git a/server/server/models/video/formatter/video-activity-pub-format.ts b/server/server/models/video/formatter/video-activity-pub-format.ts index 759e6dbbc..d19bb1880 100644 --- a/server/server/models/video/formatter/video-activity-pub-format.ts +++ b/server/server/models/video/formatter/video-activity-pub-format.ts @@ -13,6 +13,7 @@ import { } from '@peertube/peertube-models' import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js' import { + getLocalVideoChaptersActivityPubUrl, getLocalVideoCommentsActivityPubUrl, getLocalVideoDislikesActivityPubUrl, getLocalVideoLikesActivityPubUrl, @@ -95,6 +96,7 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject { dislikes: getLocalVideoDislikesActivityPubUrl(video), shares: getLocalVideoSharesActivityPubUrl(video), comments: getLocalVideoCommentsActivityPubUrl(video), + hasParts: getLocalVideoChaptersActivityPubUrl(video), attributedTo: [ { diff --git a/server/server/models/video/video-chapter.ts b/server/server/models/video/video-chapter.ts new file mode 100644 index 000000000..6e59abec9 --- /dev/null +++ b/server/server/models/video/video-chapter.ts @@ -0,0 +1,95 @@ +import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' +import { MVideo, MVideoChapter } from '@server/types/models/index.js' +import { VideoChapter, VideoChapterObject } from '@peertube/peertube-models' +import { AttributesOnly } from '@peertube/peertube-typescript-utils' +import { VideoModel } from './video.js' +import { Transaction } from 'sequelize' +import { getSort } from '../shared/sort.js' + +@Table({ + tableName: 'videoChapter', + indexes: [ + { + fields: [ 'videoId', 'timecode' ], + unique: true + } + ] +}) +export class VideoChapterModel extends Model>> { + + @AllowNull(false) + @Column + timecode: number + + @AllowNull(false) + @Column + title: string + + @ForeignKey(() => VideoModel) + @Column + videoId: number + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: false + }, + onDelete: 'CASCADE' + }) + Video: Awaited + + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + static deleteChapters (videoId: number, transaction: Transaction) { + const query = { + where: { + videoId + }, + transaction + } + + return VideoChapterModel.destroy(query) + } + + static listChaptersOfVideo (videoId: number, transaction?: Transaction) { + const query = { + where: { + videoId + }, + order: getSort('timecode'), + transaction + } + + return VideoChapterModel.findAll(query) + } + + static hasVideoChapters (videoId: number, transaction: Transaction) { + return VideoChapterModel.findOne({ + where: { videoId }, + transaction + }).then(c => !!c) + } + + toActivityPubJSON (this: MVideoChapter, options: { + video: MVideo + nextChapter: MVideoChapter + }): VideoChapterObject { + return { + name: this.title, + startOffset: this.timecode, + endOffset: options.nextChapter + ? options.nextChapter.timecode + : options.video.duration + } + } + + toFormattedJSON (this: MVideoChapter): VideoChapter { + return { + timecode: this.timecode, + title: this.title + } + } +} diff --git a/server/server/types/models/account/account.ts b/server/server/types/models/account/account.ts index 4a5e80725..a8ff058ed 100644 --- a/server/server/types/models/account/account.ts +++ b/server/server/types/models/account/account.ts @@ -14,7 +14,7 @@ import { MActorSummaryFormattable, MActorUrl } from '../actor/index.js' -import { MChannelDefault } from '../video/video-channels.js' +import { MChannelDefault } from '../video/video-channel.js' import { MAccountBlocklistId } from './account-blocklist.js' type Use = PickWith diff --git a/server/server/types/models/user/user.ts b/server/server/types/models/user/user.ts index 4a655c792..3d0bee1aa 100644 --- a/server/server/types/models/user/user.ts +++ b/server/server/types/models/user/user.ts @@ -11,7 +11,7 @@ import { MAccountIdActorId, MAccountUrl } from '../account/index.js' -import { MChannelFormattable } from '../video/video-channels.js' +import { MChannelFormattable } from '../video/video-channel.js' import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting.js' type Use = PickWith diff --git a/server/server/types/models/video/index.ts b/server/server/types/models/video/index.ts index f88198b67..0eeb7aad2 100644 --- a/server/server/types/models/video/index.ts +++ b/server/server/types/models/video/index.ts @@ -10,7 +10,8 @@ export * from './video-blacklist.js' export * from './video-caption.js' export * from './video-change-ownership.js' export * from './video-channel-sync.js' -export * from './video-channels.js' +export * from './video-channel.js' +export * from './video-chapter.js' export * from './video-comment.js' export * from './video-file.js' export * from './video-import.js' diff --git a/server/server/types/models/video/video-channel-sync.ts b/server/server/types/models/video/video-channel-sync.ts index 2b3a3930f..7e4f9373b 100644 --- a/server/server/types/models/video/video-channel-sync.ts +++ b/server/server/types/models/video/video-channel-sync.ts @@ -1,6 +1,6 @@ import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js' import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils' -import { MChannelAccountDefault, MChannelFormattable } from './video-channels.js' +import { MChannelAccountDefault, MChannelFormattable } from './video-channel.js' type Use = PickWith diff --git a/server/server/types/models/video/video-channels.ts b/server/server/types/models/video/video-channel.ts similarity index 100% rename from server/server/types/models/video/video-channels.ts rename to server/server/types/models/video/video-channel.ts diff --git a/server/server/types/models/video/video-chapter.ts b/server/server/types/models/video/video-chapter.ts new file mode 100644 index 000000000..377cf213a --- /dev/null +++ b/server/server/types/models/video/video-chapter.ts @@ -0,0 +1,3 @@ +import { VideoChapterModel } from '@server/models/video/video-chapter.js' + +export type MVideoChapter = Omit diff --git a/server/server/types/models/video/video-playlist.ts b/server/server/types/models/video/video-playlist.ts index 3d99bf4e5..152904d22 100644 --- a/server/server/types/models/video/video-playlist.ts +++ b/server/server/types/models/video/video-playlist.ts @@ -3,7 +3,7 @@ import { PickWith } from '@peertube/peertube-typescript-utils' import { VideoPlaylistModel } from '../../../models/video/video-playlist.js' import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account/index.js' import { MThumbnail } from './thumbnail.js' -import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channels.js' +import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channel.js' type Use = PickWith diff --git a/server/server/types/models/video/video.ts b/server/server/types/models/video/video.ts index b7f8652be..f9141681b 100644 --- a/server/server/types/models/video/video.ts +++ b/server/server/types/models/video/video.ts @@ -16,7 +16,7 @@ import { MChannelFormattable, MChannelHostOnly, MChannelUserId -} from './video-channels.js' +} from './video-channel.js' import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js' import { MVideoLive } from './video-live.js' import { diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 8d85f9c77..e3931a36e 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -257,6 +257,8 @@ tags: description: Operations dealing with synchronizing PeerTube user's channel with channels of other platforms - name: Video Captions description: Operations dealing with listing, adding and removing closed captions of a video. + - name: Video Chapters + description: Operations dealing with managing chapters of a video. - name: Video Channels description: Operations dealing with the creation, modification and listing of videos within a channel. - name: Video Comments @@ -328,6 +330,7 @@ x-tagGroups: - Video Upload - Video Imports - Video Captions + - Video Chapters - Video Channels - Video Comments - Video Rates @@ -3242,7 +3245,7 @@ paths: '/api/v1/videos/{id}/source/replace-resumable': post: summary: Initialize the resumable replacement of a video - description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to initialize the replacement of a video + description: "**PeerTube >= 6.0** Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to initialize the replacement of a video" operationId: replaceVideoSourceResumableInit security: - OAuth2: [] @@ -3281,7 +3284,7 @@ paths: description: video type unsupported put: summary: Send chunk for the resumable replacement of a video - description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to continue, pause or resume the replacement of a video + description: "**PeerTube >= 6.0** Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to continue, pause or resume the replacement of a video" operationId: replaceVideoSourceResumable security: - OAuth2: [] @@ -3331,7 +3334,7 @@ paths: example: 300 delete: summary: Cancel the resumable replacement of a video - description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to cancel the replacement of a video + description: "**PeerTube >= 6.0** Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to cancel the replacement of a video" operationId: replaceVideoSourceResumableCancel security: - OAuth2: [] @@ -3742,6 +3745,7 @@ paths: /api/v1/videos/{id}/storyboards: get: summary: List storyboards of a video + description: "**PeerTube** >= 6.0" operationId: listVideoStoryboards tags: - Video @@ -3832,9 +3836,59 @@ paths: '404': description: video or language or caption for that language not found + /api/v1/videos/{id}/chapters: + get: + summary: Get chapters of a video + description: "**PeerTube** >= 6.0" + operationId: getVideoChapters + tags: + - Video Chapters + parameters: + - $ref: '#/components/parameters/idOrUUID' + - $ref: '#/components/parameters/videoPasswordHeader' + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/VideoChapters' + put: + summary: Replace video chapters + description: "**PeerTube** >= 6.0" + operationId: replaceVideoChapters + security: + - OAuth2: + - user + tags: + - Video Chapters + parameters: + - $ref: '#/components/parameters/idOrUUID' + requestBody: + content: + application/json: + schema: + type: object + properties: + chapters: + type: array + items: + type: object + properties: + title: + type: string + timecode: + type: integer + responses: + '204': + description: successful operation + '404': + description: video not found + /api/v1/videos/{id}/passwords: get: summary: List video passwords + description: "**PeerTube** >= 6.0" security: - OAuth2: - user @@ -3856,6 +3910,7 @@ paths: description: video is not password protected put: summary: Update video passwords + description: "**PeerTube** >= 6.0" security: - OAuth2: - user @@ -3880,6 +3935,7 @@ paths: /api/v1/videos/{id}/passwords/{videoPasswordId}: delete: summary: Delete a video password + description: "**PeerTube** >= 6.0" security: - OAuth2: - user @@ -7704,6 +7760,15 @@ components: $ref: '#/components/schemas/VideoConstantString-Language' captionPath: type: string + VideoChapters: + properties: + chapters: + type: object + properties: + title: + type: string + timecode: + type: integer VideoSource: properties: filename: