From 77b70702d2193d78bf6fbd07f0fc7335e34957f8 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 28 Aug 2023 10:55:04 +0200 Subject: [PATCH] Add video chapters support --- .../shared/video-edit.component.html | 52 ++- .../shared/video-edit.component.scss | 26 ++ .../shared/video-edit.component.ts | 98 ++++- .../video-go-live.component.ts | 7 +- .../video-import-torrent.component.ts | 30 +- .../video-import-url.component.html | 1 + .../video-import-url.component.ts | 19 +- .../video-add-components/video-send.ts | 28 +- .../video-upload.component.ts | 7 +- .../+video-edit/video-update.component.html | 1 + .../+video-edit/video-update.component.ts | 33 +- .../+video-edit/video-update.resolver.ts | 13 +- .../+video-watch/video-watch.component.ts | 16 +- client/src/app/helpers/utils/object.ts | 12 - .../app/menu/language-chooser.component.ts | 4 +- .../video-chapter-validators.ts | 32 ++ .../form-validators/video-validators.ts | 8 - .../shared-forms/form-reactive.service.ts | 6 +- .../shared-forms/form-validator.service.ts | 59 ++- .../shared-forms/input-text.component.ts | 6 +- .../markdown-textarea.component.html | 2 +- .../markdown-textarea.component.ts | 3 +- .../timestamp-input.component.scss | 1 + .../shared-main/buttons/button.component.html | 2 +- .../shared/shared-main/shared-main.module.ts | 3 + .../video-caption/video-caption.service.ts | 4 +- .../src/app/shared/shared-main/video/index.ts | 2 + .../video/video-chapter.service.ts | 34 ++ .../video/video-chapters-edit.model.ts | 43 +++ client/src/assets/player/peertube-player.ts | 7 + .../shared/control-bar/chapters-plugin.ts | 64 ++++ .../assets/player/shared/control-bar/index.ts | 2 + .../progress-bar-marker-component.ts | 24 ++ .../shared/control-bar/storyboard-plugin.ts | 4 +- .../player/shared/control-bar/time-tooltip.ts | 20 + .../player/types/peertube-player-options.ts | 3 +- .../player/types/peertube-videojs-typings.ts | 15 +- client/src/sass/player/control-bar.scss | 19 + client/src/standalone/videos/embed.ts | 9 +- .../videos/shared/player-options-builder.ts | 18 +- .../standalone/videos/shared/video-fetcher.ts | 7 +- packages/core-utils/src/common/array.ts | 22 +- packages/core-utils/src/common/date.ts | 4 +- packages/core-utils/src/index.ts | 1 + packages/core-utils/src/string/chapters.ts | 32 ++ packages/core-utils/src/string/index.ts | 1 + packages/ffmpeg/src/ffprobe.ts | 19 +- packages/models/src/activitypub/context.ts | 3 +- .../models/src/activitypub/objects/index.ts | 1 + .../objects/video-chapters-object.ts | 11 + .../src/activitypub/objects/video-object.ts | 1 + .../videos/chapter/chapter-update.model.ts | 6 + .../src/videos/chapter/chapter.model.ts | 4 + packages/models/src/videos/chapter/index.ts | 2 + packages/models/src/videos/index.ts | 1 + packages/server-commands/src/server/server.ts | 3 + .../src/videos/chapters-command.ts | 38 ++ packages/server-commands/src/videos/index.ts | 1 + packages/tests/fixtures/video_chapters.mp4 | Bin 0 -> 39611 bytes packages/tests/src/api/check-params/index.ts | 1 + .../src/api/check-params/video-captions.ts | 23 +- .../src/api/check-params/video-chapters.ts | 172 +++++++++ packages/tests/src/api/videos/index.ts | 1 + .../tests/src/api/videos/video-chapters.ts | 342 ++++++++++++++++++ .../tests/src/server-helpers/core-utils.ts | 27 +- packages/tests/src/shared/tests.ts | 3 + packages/typescript-utils/src/types.ts | 2 + .../server/controllers/activitypub/client.ts | 65 +++- .../server/controllers/api/videos/chapters.ts | 51 +++ server/server/controllers/api/videos/index.ts | 2 + .../server/controllers/api/videos/update.ts | 11 + .../server/controllers/api/videos/upload.ts | 9 + server/server/helpers/activity-pub-utils.ts | 11 +- .../activitypub/video-chapters.ts | 15 + .../custom-validators/video-chapters.ts | 26 ++ .../youtube-dl/youtube-dl-info-builder.ts | 11 +- server/server/initializers/constants.ts | 3 + server/server/initializers/database.ts | 2 + server/server/lib/activitypub/url.ts | 5 + .../videos/shared/abstract-builder.ts | 36 +- .../lib/activitypub/videos/shared/creator.ts | 2 + .../server/lib/activitypub/videos/updater.ts | 2 + server/server/lib/internal-event-emitter.ts | 4 +- .../lib/job-queue/handlers/video-import.ts | 6 + server/server/lib/video-chapters.ts | 99 +++++ server/server/lib/video-pre-import.ts | 24 ++ server/server/middlewares/cache/cache.ts | 36 +- server/server/middlewares/validators/feeds.ts | 11 - .../middlewares/validators/videos/index.ts | 1 + .../validators/videos/video-chapters.ts | 34 ++ .../formatter/video-activity-pub-format.ts | 2 + server/server/models/video/video-chapter.ts | 95 +++++ server/server/types/models/account/account.ts | 2 +- server/server/types/models/user/user.ts | 2 +- server/server/types/models/video/index.ts | 3 +- .../types/models/video/video-channel-sync.ts | 2 +- .../{video-channels.ts => video-channel.ts} | 0 .../types/models/video/video-chapter.ts | 3 + .../types/models/video/video-playlist.ts | 2 +- server/server/types/models/video/video.ts | 2 +- support/doc/api/openapi.yaml | 71 +++- 101 files changed, 1957 insertions(+), 158 deletions(-) create mode 100644 client/src/app/shared/form-validators/video-chapter-validators.ts create mode 100644 client/src/app/shared/shared-main/video/video-chapter.service.ts create mode 100644 client/src/app/shared/shared-main/video/video-chapters-edit.model.ts create mode 100644 client/src/assets/player/shared/control-bar/chapters-plugin.ts create mode 100644 client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts create mode 100644 client/src/assets/player/shared/control-bar/time-tooltip.ts create mode 100644 packages/core-utils/src/string/chapters.ts create mode 100644 packages/core-utils/src/string/index.ts create mode 100644 packages/models/src/activitypub/objects/video-chapters-object.ts create mode 100644 packages/models/src/videos/chapter/chapter-update.model.ts create mode 100644 packages/models/src/videos/chapter/chapter.model.ts create mode 100644 packages/models/src/videos/chapter/index.ts create mode 100644 packages/server-commands/src/videos/chapters-command.ts create mode 100644 packages/tests/fixtures/video_chapters.mp4 create mode 100644 packages/tests/src/api/check-params/video-chapters.ts create mode 100644 packages/tests/src/api/videos/video-chapters.ts create mode 100644 server/server/controllers/api/videos/chapters.ts create mode 100644 server/server/helpers/custom-validators/activitypub/video-chapters.ts create mode 100644 server/server/helpers/custom-validators/video-chapters.ts create mode 100644 server/server/lib/video-chapters.ts create mode 100644 server/server/middlewares/validators/videos/video-chapters.ts create mode 100644 server/server/models/video/video-chapter.ts rename server/server/types/models/video/{video-channels.ts => video-channel.ts} (100%) create mode 100644 server/server/types/models/video/video-chapter.ts 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 0000000000000000000000000000000000000000..46cbaf6245bc7798aee40c9db95d9bc91e46677f GIT binary patch literal 39611 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 a-nL#$U_t@Hg1o;;z;pnrS{eT)5&l1!YCnho literal 0 HcmV?d00001 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: