aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2023-08-28 10:55:04 +0200
committerChocobozzz <me@florianbigard.com>2023-08-28 16:17:31 +0200
commit77b70702d2193d78bf6fbd07f0fc7335e34957f8 (patch)
tree1a0aed540054286c9a8b10c4890cc0f718e00458
parent7113f32a87bd6b2868154fed20bde1a1633c190e (diff)
downloadPeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.tar.gz
PeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.tar.zst
PeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.zip
Add video chapters support
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.html52
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.scss26
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.ts98
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts7
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts34
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html1
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts19
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-send.ts28
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts7
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.html1
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.ts33
-rw-r--r--client/src/app/+videos/+video-edit/video-update.resolver.ts13
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts16
-rw-r--r--client/src/app/helpers/utils/object.ts12
-rw-r--r--client/src/app/menu/language-chooser.component.ts4
-rw-r--r--client/src/app/shared/form-validators/video-chapter-validators.ts32
-rw-r--r--client/src/app/shared/form-validators/video-validators.ts8
-rw-r--r--client/src/app/shared/shared-forms/form-reactive.service.ts6
-rw-r--r--client/src/app/shared/shared-forms/form-validator.service.ts59
-rw-r--r--client/src/app/shared/shared-forms/input-text.component.ts6
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.html2
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.ts3
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.scss1
-rw-r--r--client/src/app/shared/shared-main/buttons/button.component.html2
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts3
-rw-r--r--client/src/app/shared/shared-main/video-caption/video-caption.service.ts4
-rw-r--r--client/src/app/shared/shared-main/video/index.ts2
-rw-r--r--client/src/app/shared/shared-main/video/video-chapter.service.ts34
-rw-r--r--client/src/app/shared/shared-main/video/video-chapters-edit.model.ts43
-rw-r--r--client/src/assets/player/peertube-player.ts7
-rw-r--r--client/src/assets/player/shared/control-bar/chapters-plugin.ts64
-rw-r--r--client/src/assets/player/shared/control-bar/index.ts2
-rw-r--r--client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts24
-rw-r--r--client/src/assets/player/shared/control-bar/storyboard-plugin.ts4
-rw-r--r--client/src/assets/player/shared/control-bar/time-tooltip.ts20
-rw-r--r--client/src/assets/player/types/peertube-player-options.ts3
-rw-r--r--client/src/assets/player/types/peertube-videojs-typings.ts15
-rw-r--r--client/src/sass/player/control-bar.scss19
-rw-r--r--client/src/standalone/videos/embed.ts9
-rw-r--r--client/src/standalone/videos/shared/player-options-builder.ts18
-rw-r--r--client/src/standalone/videos/shared/video-fetcher.ts7
-rw-r--r--packages/core-utils/src/common/array.ts22
-rw-r--r--packages/core-utils/src/common/date.ts4
-rw-r--r--packages/core-utils/src/index.ts1
-rw-r--r--packages/core-utils/src/string/chapters.ts32
-rw-r--r--packages/core-utils/src/string/index.ts1
-rw-r--r--packages/ffmpeg/src/ffprobe.ts19
-rw-r--r--packages/models/src/activitypub/context.ts3
-rw-r--r--packages/models/src/activitypub/objects/index.ts1
-rw-r--r--packages/models/src/activitypub/objects/video-chapters-object.ts11
-rw-r--r--packages/models/src/activitypub/objects/video-object.ts1
-rw-r--r--packages/models/src/videos/chapter/chapter-update.model.ts6
-rw-r--r--packages/models/src/videos/chapter/chapter.model.ts4
-rw-r--r--packages/models/src/videos/chapter/index.ts2
-rw-r--r--packages/models/src/videos/index.ts1
-rw-r--r--packages/server-commands/src/server/server.ts3
-rw-r--r--packages/server-commands/src/videos/chapters-command.ts38
-rw-r--r--packages/server-commands/src/videos/index.ts1
-rw-r--r--packages/tests/fixtures/video_chapters.mp4bin0 -> 39611 bytes
-rw-r--r--packages/tests/src/api/check-params/index.ts1
-rw-r--r--packages/tests/src/api/check-params/video-captions.ts23
-rw-r--r--packages/tests/src/api/check-params/video-chapters.ts172
-rw-r--r--packages/tests/src/api/videos/index.ts1
-rw-r--r--packages/tests/src/api/videos/video-chapters.ts342
-rw-r--r--packages/tests/src/server-helpers/core-utils.ts27
-rw-r--r--packages/tests/src/shared/tests.ts3
-rw-r--r--packages/typescript-utils/src/types.ts2
-rw-r--r--server/server/controllers/activitypub/client.ts65
-rw-r--r--server/server/controllers/api/videos/chapters.ts51
-rw-r--r--server/server/controllers/api/videos/index.ts2
-rw-r--r--server/server/controllers/api/videos/update.ts11
-rw-r--r--server/server/controllers/api/videos/upload.ts9
-rw-r--r--server/server/helpers/activity-pub-utils.ts11
-rw-r--r--server/server/helpers/custom-validators/activitypub/video-chapters.ts15
-rw-r--r--server/server/helpers/custom-validators/video-chapters.ts26
-rw-r--r--server/server/helpers/youtube-dl/youtube-dl-info-builder.ts11
-rw-r--r--server/server/initializers/constants.ts3
-rw-r--r--server/server/initializers/database.ts2
-rw-r--r--server/server/lib/activitypub/url.ts5
-rw-r--r--server/server/lib/activitypub/videos/shared/abstract-builder.ts36
-rw-r--r--server/server/lib/activitypub/videos/shared/creator.ts2
-rw-r--r--server/server/lib/activitypub/videos/updater.ts2
-rw-r--r--server/server/lib/internal-event-emitter.ts4
-rw-r--r--server/server/lib/job-queue/handlers/video-import.ts6
-rw-r--r--server/server/lib/video-chapters.ts99
-rw-r--r--server/server/lib/video-pre-import.ts24
-rw-r--r--server/server/middlewares/cache/cache.ts38
-rw-r--r--server/server/middlewares/validators/feeds.ts11
-rw-r--r--server/server/middlewares/validators/videos/index.ts1
-rw-r--r--server/server/middlewares/validators/videos/video-chapters.ts34
-rw-r--r--server/server/models/video/formatter/video-activity-pub-format.ts2
-rw-r--r--server/server/models/video/video-chapter.ts95
-rw-r--r--server/server/types/models/account/account.ts2
-rw-r--r--server/server/types/models/user/user.ts2
-rw-r--r--server/server/types/models/video/index.ts3
-rw-r--r--server/server/types/models/video/video-channel-sync.ts2
-rw-r--r--server/server/types/models/video/video-channel.ts (renamed from server/server/types/models/video/video-channels.ts)0
-rw-r--r--server/server/types/models/video/video-chapter.ts3
-rw-r--r--server/server/types/models/video/video-playlist.ts2
-rw-r--r--server/server/types/models/video/video.ts2
-rw-r--r--support/doc/api/openapi.yaml71
101 files changed, 1960 insertions, 161 deletions
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 @@
230 </ng-template> 230 </ng-template>
231 </ng-container> 231 </ng-container>
232 232
233 <ng-container ngbNavItem *ngIf="!liveVideo">
234 <a ngbNavLink i18n>Chapters</a>
235
236 <ng-template ngbNavContent>
237 <div class="row mb-5">
238 <div class="chapters col-md-12 col-xl-6" formArrayName="chapters">
239 <ng-container *ngFor="let chapterControl of getChaptersFormArray().controls; let i = index">
240 <div class="chapter" [formGroupName]="i">
241 <!-- Row 1 -->
242 <div></div>
243
244 <label i18n [ngClass]="{ 'hide-chapter-label': i !== 0 }" [for]="'timecode[' + i + ']'">Timecode</label>
245
246 <label i18n [ngClass]="{ 'hide-chapter-label': i !== 0 }" [for]="'title[' + i + ']'">Chapter name</label>
247
248 <div></div>
249
250 <!-- Row 2 -->
251 <div class="position">{{ i + 1 }}</div>
252
253 <my-timestamp-input
254 class="d-block" [disableBorder]="false" [inputName]="'timecode[' + i + ']'"
255 [maxTimestamp]="videoToUpdate?.duration" formControlName="timecode"
256 ></my-timestamp-input>
257
258 <div>
259 <input
260 [ngClass]="{ 'input-error': formErrors.chapters[i].title }"
261 type="text" [id]="'title[' + i + ']'" [name]="'title[' + i + ']'" formControlName="title"
262 />
263
264 <div [ngClass]="{ 'opacity-0': !formErrors.chapters[i].title }" class="form-error">
265 <span class="opacity-0">t</span> <!-- Ensure we have reserve a correct height -->
266 {{ formErrors.chapters[i].title }}
267 </div>
268 </div>
269
270 <my-delete-button *ngIf="!isLastChapterControl(i)" (click)="deleteChapterControl(i)"></my-delete-button>
271 </div>
272 </ng-container>
273
274 <div *ngIf="getChapterArrayErrors()" class="form-error">
275 {{ getChapterArrayErrors() }}
276 </div>
277 </div>
278
279 <my-embed *ngIf="videoToUpdate" class="col-md-12 col-xl-6" [video]="videoToUpdate"></my-embed>
280 </div>
281 </ng-template>
282 </ng-container>
283
233 <ng-container ngbNavItem *ngIf="liveVideo"> 284 <ng-container ngbNavItem *ngIf="liveVideo">
234 <a ngbNavLink i18n>Live settings</a> 285 <a ngbNavLink i18n>Live settings</a>
235 286
@@ -312,7 +363,6 @@
312 363
313 </ng-container> 364 </ng-container>
314 365
315
316 <ng-container ngbNavItem> 366 <ng-container ngbNavItem>
317 <a ngbNavLink i18n>Advanced settings</a> 367 <a ngbNavLink i18n>Advanced settings</a>
318 368
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 {
117 @include orange-button; 117 @include orange-button;
118} 118}
119 119
120.hide-chapter-label {
121 height: 0;
122 opacity: 0;
123}
124
125.chapter {
126 display: grid;
127 grid-template-columns: auto auto minmax(150px, 350px) 1fr;
128 grid-template-rows: auto auto;
129 column-gap: 1rem;
130
131 .position {
132 height: 31px;
133 display: flex;
134 align-items: center;
135 }
136
137 my-delete-button {
138 width: fit-content;
139 }
140
141 .form-error {
142 margin-top: 0;
143 }
144}
145
120@include on-small-main-col { 146@include on-small-main-col {
121 .form-columns { 147 .form-columns {
122 grid-template-columns: 1fr; 148 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'
2import { map } from 'rxjs/operators' 2import { map } from 'rxjs/operators'
3import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model' 3import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model'
4import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' 4import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
5import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms' 5import { AbstractControl, FormArray, FormGroup, Validators } from '@angular/forms'
6import { HooksService, PluginService, ServerService } from '@app/core' 6import { HooksService, PluginService, ServerService } from '@app/core'
7import { removeElementFromArray } from '@app/helpers' 7import { removeElementFromArray } from '@app/helpers'
8import { BuildFormValidator } from '@app/shared/form-validators' 8import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators'
9import { 9import {
10 VIDEO_CATEGORY_VALIDATOR, 10 VIDEO_CATEGORY_VALIDATOR,
11 VIDEO_CHANNEL_VALIDATOR, 11 VIDEO_CHANNEL_VALIDATOR,
@@ -20,9 +20,10 @@ import {
20 VIDEO_SUPPORT_VALIDATOR, 20 VIDEO_SUPPORT_VALIDATOR,
21 VIDEO_TAGS_ARRAY_VALIDATOR 21 VIDEO_TAGS_ARRAY_VALIDATOR
22} from '@app/shared/form-validators/video-validators' 22} from '@app/shared/form-validators/video-validators'
23import { FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms' 23import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators'
24import { FormReactiveErrors, FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms'
24import { InstanceService } from '@app/shared/shared-instance' 25import { InstanceService } from '@app/shared/shared-instance'
25import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoEdit, VideoService } from '@app/shared/shared-main' 26import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoChaptersEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
26import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 27import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
27import { 28import {
28 HTMLServerConfig, 29 HTMLServerConfig,
@@ -30,6 +31,7 @@ import {
30 LiveVideoLatencyMode, 31 LiveVideoLatencyMode,
31 RegisterClientFormFieldOptions, 32 RegisterClientFormFieldOptions,
32 RegisterClientVideoFieldOptions, 33 RegisterClientVideoFieldOptions,
34 VideoChapter,
33 VideoConstant, 35 VideoConstant,
34 VideoDetails, 36 VideoDetails,
35 VideoPrivacy, 37 VideoPrivacy,
@@ -57,7 +59,7 @@ type PluginField = {
57}) 59})
58export class VideoEditComponent implements OnInit, OnDestroy { 60export class VideoEditComponent implements OnInit, OnDestroy {
59 @Input() form: FormGroup 61 @Input() form: FormGroup
60 @Input() formErrors: { [ id: string ]: string } = {} 62 @Input() formErrors: FormReactiveErrors & { chapters?: { title: string }[] } = {}
61 @Input() validationMessages: FormReactiveValidationMessages = {} 63 @Input() validationMessages: FormReactiveValidationMessages = {}
62 64
63 @Input() videoToUpdate: VideoDetails 65 @Input() videoToUpdate: VideoDetails
@@ -68,6 +70,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
68 @Input() videoCaptions: VideoCaptionWithPathEdit[] = [] 70 @Input() videoCaptions: VideoCaptionWithPathEdit[] = []
69 @Input() videoSource: VideoSource 71 @Input() videoSource: VideoSource
70 72
73 @Input() videoChapters: VideoChapter[] = []
74
71 @Input() hideWaitTranscoding = false 75 @Input() hideWaitTranscoding = false
72 @Input() updateVideoFileEnabled = false 76 @Input() updateVideoFileEnabled = false
73 77
@@ -150,7 +154,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
150 licence: this.serverConfig.defaults.publish.licence, 154 licence: this.serverConfig.defaults.publish.licence,
151 tags: [] 155 tags: []
152 } 156 }
153 const obj: { [ id: string ]: BuildFormValidator } = { 157 const obj: BuildFormArgument = {
154 name: VIDEO_NAME_VALIDATOR, 158 name: VIDEO_NAME_VALIDATOR,
155 privacy: VIDEO_PRIVACY_VALIDATOR, 159 privacy: VIDEO_PRIVACY_VALIDATOR,
156 videoPassword: VIDEO_PASSWORD_VALIDATOR, 160 videoPassword: VIDEO_PASSWORD_VALIDATOR,
@@ -183,12 +187,16 @@ export class VideoEditComponent implements OnInit, OnDestroy {
183 defaultValues 187 defaultValues
184 ) 188 )
185 189
186 this.form.addControl('captions', new FormArray([ 190 this.form.addControl('chapters', new FormArray([], VIDEO_CHAPTERS_ARRAY_VALIDATOR.VALIDATORS))
187 new FormGroup({ 191 this.addNewChapterControl()
188 language: new FormControl(), 192
189 captionfile: new FormControl() 193 this.form.get('chapters').valueChanges.subscribe((chapters: { title: string, timecode: string }[]) => {
190 }) 194 const lastChapter = chapters[chapters.length - 1]
191 ])) 195
196 if (lastChapter.title || lastChapter.timecode) {
197 this.addNewChapterControl()
198 }
199 })
192 200
193 this.trackChannelChange() 201 this.trackChannelChange()
194 this.trackPrivacyChange() 202 this.trackPrivacyChange()
@@ -426,6 +434,70 @@ export class VideoEditComponent implements OnInit, OnDestroy {
426 this.form.valueChanges.subscribe(() => this.formValidatorService.updateTreeValidity(this.pluginDataFormGroup)) 434 this.form.valueChanges.subscribe(() => this.formValidatorService.updateTreeValidity(this.pluginDataFormGroup))
427 } 435 }
428 436
437 // ---------------------------------------------------------------------------
438
439 addNewChapterControl () {
440 const chaptersFormArray = this.getChaptersFormArray()
441 const controls = chaptersFormArray.controls
442
443 if (controls.length !== 0) {
444 const lastControl = chaptersFormArray.controls[controls.length - 1]
445 lastControl.get('title').addValidators(Validators.required)
446 }
447
448 this.formValidatorService.addControlInFormArray({
449 controlName: 'chapters',
450 formArray: chaptersFormArray,
451 formErrors: this.formErrors,
452 validationMessages: this.validationMessages,
453 formToBuild: {
454 timecode: null,
455 title: VIDEO_CHAPTER_TITLE_VALIDATOR
456 },
457 defaultValues: {
458 timecode: 0
459 }
460 })
461 }
462
463 getChaptersFormArray () {
464 return this.form.controls['chapters'] as FormArray
465 }
466
467 deleteChapterControl (index: number) {
468 this.formValidatorService.removeControlFromFormArray({
469 controlName: 'chapters',
470 formArray: this.getChaptersFormArray(),
471 formErrors: this.formErrors,
472 validationMessages: this.validationMessages,
473 index
474 })
475 }
476
477 isLastChapterControl (index: number) {
478 return this.getChaptersFormArray().length - 1 === index
479 }
480
481 patchChapters (chaptersEdit: VideoChaptersEdit) {
482 const totalChapters = chaptersEdit.getChaptersForUpdate().length
483 const totalControls = this.getChaptersFormArray().length
484
485 // Add missing controls. We use <= because we need the "empty control" to add another chapter
486 for (let i = 0; i <= totalChapters - totalControls; i++) {
487 this.addNewChapterControl()
488 }
489
490 this.form.patchValue(chaptersEdit.toFormPatch())
491 }
492
493 getChapterArrayErrors () {
494 if (!this.getChaptersFormArray().errors) return ''
495
496 return Object.values(this.getChaptersFormArray().errors).join('. ')
497 }
498
499 // ---------------------------------------------------------------------------
500
429 private trackPrivacyChange () { 501 private trackPrivacyChange () {
430 // We will update the schedule input and the wait transcoding checkbox validators 502 // We will update the schedule input and the wait transcoding checkbox validators
431 this.form.controls['privacy'] 503 this.form.controls['privacy']
@@ -469,8 +541,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
469 } else { 541 } else {
470 videoPasswordControl.clearValidators() 542 videoPasswordControl.clearValidators()
471 } 543 }
472 videoPasswordControl.updateValueAndValidity()
473 544
545 videoPasswordControl.updateValueAndValidity()
474 } 546 }
475 ) 547 )
476 } 548 }
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'
4import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' 4import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
5import { scrollToTop } from '@app/helpers' 5import { scrollToTop } from '@app/helpers'
6import { FormReactiveService } from '@app/shared/shared-forms' 6import { FormReactiveService } from '@app/shared/shared-forms'
7import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' 7import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main'
8import { LiveVideoService } from '@app/shared/shared-video-live' 8import { LiveVideoService } from '@app/shared/shared-video-live'
9import { LoadingBarService } from '@ngx-loading-bar/core' 9import { LoadingBarService } from '@ngx-loading-bar/core'
10import { logger } from '@root-helpers/logger' 10import { logger } from '@root-helpers/logger'
@@ -54,6 +54,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
54 protected serverService: ServerService, 54 protected serverService: ServerService,
55 protected videoService: VideoService, 55 protected videoService: VideoService,
56 protected videoCaptionService: VideoCaptionService, 56 protected videoCaptionService: VideoCaptionService,
57 protected videoChapterService: VideoChapterService,
57 private liveVideoService: LiveVideoService, 58 private liveVideoService: LiveVideoService,
58 private router: Router, 59 private router: Router,
59 private hooks: HooksService 60 private hooks: HooksService
@@ -137,6 +138,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
137 video.uuid = this.videoUUID 138 video.uuid = this.videoUUID
138 video.shortUUID = this.videoShortUUID 139 video.shortUUID = this.videoShortUUID
139 140
141 this.chaptersEdit.patch(this.form.value)
142
140 const saveReplay = this.form.value.saveReplay 143 const saveReplay = this.form.value.saveReplay
141 const replaySettings = saveReplay 144 const replaySettings = saveReplay
142 ? { privacy: this.form.value.replayPrivacy } 145 ? { privacy: this.form.value.replayPrivacy }
@@ -151,7 +154,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
151 154
152 // Update the video 155 // Update the video
153 forkJoin([ 156 forkJoin([
154 this.updateVideoAndCaptions(video), 157 this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions }),
155 158
156 this.liveVideoService.updateLive(this.videoId, liveVideoUpdate) 159 this.liveVideoService.updateLive(this.videoId, liveVideoUpdate)
157 ]).subscribe({ 160 ]).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'
4import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' 4import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
5import { scrollToTop } from '@app/helpers' 5import { scrollToTop } from '@app/helpers'
6import { FormReactiveService } from '@app/shared/shared-forms' 6import { FormReactiveService } from '@app/shared/shared-forms'
7import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' 7import { VideoCaptionService, VideoChapterService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
8import { LoadingBarService } from '@ngx-loading-bar/core' 8import { LoadingBarService } from '@ngx-loading-bar/core'
9import { logger } from '@root-helpers/logger' 9import { logger } from '@root-helpers/logger'
10import { PeerTubeProblemDocument, ServerErrorCode, VideoUpdate } from '@peertube/peertube-models' 10import { PeerTubeProblemDocument, ServerErrorCode, VideoUpdate } from '@peertube/peertube-models'
@@ -42,6 +42,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af
42 protected serverService: ServerService, 42 protected serverService: ServerService,
43 protected videoService: VideoService, 43 protected videoService: VideoService,
44 protected videoCaptionService: VideoCaptionService, 44 protected videoCaptionService: VideoCaptionService,
45 protected videoChapterService: VideoChapterService,
45 private router: Router, 46 private router: Router,
46 private videoImportService: VideoImportService, 47 private videoImportService: VideoImportService,
47 private hooks: HooksService 48 private hooks: HooksService
@@ -124,24 +125,25 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af
124 if (!await this.isFormValid()) return 125 if (!await this.isFormValid()) return
125 126
126 this.video.patch(this.form.value) 127 this.video.patch(this.form.value)
128 this.chaptersEdit.patch(this.form.value)
127 129
128 this.isUpdatingVideo = true 130 this.isUpdatingVideo = true
129 131
130 // Update the video 132 // Update the video
131 this.updateVideoAndCaptions(this.video) 133 this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit })
132 .subscribe({ 134 .subscribe({
133 next: () => { 135 next: () => {
134 this.isUpdatingVideo = false 136 this.isUpdatingVideo = false
135 this.notifier.success($localize`Video to import updated.`) 137 this.notifier.success($localize`Video to import updated.`)
136 138
137 this.router.navigate([ '/my-library', 'video-imports' ]) 139 this.router.navigate([ '/my-library', 'video-imports' ])
138 }, 140 },
139 141
140 error: err => { 142 error: err => {
141 this.error = err.message 143 this.error = err.message
142 scrollToTop() 144 scrollToTop()
143 logger.error(err) 145 logger.error(err)
144 } 146 }
145 }) 147 })
146 } 148 }
147} 149}
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 @@
56<!-- Hidden because we want to load the component --> 56<!-- Hidden because we want to load the component -->
57<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form"> 57<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
58 <my-video-edit 58 <my-video-edit
59 #videoEdit
59 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [forbidScheduledPublication]="true" 60 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [forbidScheduledPublication]="true"
60 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" 61 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
61 type="import-url" 62 type="import-url"
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts
index 634bd9914..4dc04b83e 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts
@@ -1,16 +1,17 @@
1import { forkJoin } from 'rxjs' 1import { forkJoin } from 'rxjs'
2import { map, switchMap } from 'rxjs/operators' 2import { map, switchMap } from 'rxjs/operators'
3import { AfterViewInit, Component, EventEmitter, OnInit, Output } from '@angular/core' 3import { AfterViewInit, Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
4import { Router } from '@angular/router' 4import { Router } from '@angular/router'
5import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' 5import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
6import { scrollToTop } from '@app/helpers' 6import { scrollToTop } from '@app/helpers'
7import { FormReactiveService } from '@app/shared/shared-forms' 7import { FormReactiveService } from '@app/shared/shared-forms'
8import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' 8import { VideoCaptionService, VideoChapterService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
9import { LoadingBarService } from '@ngx-loading-bar/core' 9import { LoadingBarService } from '@ngx-loading-bar/core'
10import { logger } from '@root-helpers/logger' 10import { logger } from '@root-helpers/logger'
11import { VideoUpdate } from '@peertube/peertube-models' 11import { VideoUpdate } from '@peertube/peertube-models'
12import { hydrateFormFromVideo } from '../shared/video-edit-utils' 12import { hydrateFormFromVideo } from '../shared/video-edit-utils'
13import { VideoSend } from './video-send' 13import { VideoSend } from './video-send'
14import { VideoEditComponent } from '../shared/video-edit.component'
14 15
15@Component({ 16@Component({
16 selector: 'my-video-import-url', 17 selector: 'my-video-import-url',
@@ -21,6 +22,8 @@ import { VideoSend } from './video-send'
21 ] 22 ]
22}) 23})
23export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterViewInit, CanComponentDeactivate { 24export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterViewInit, CanComponentDeactivate {
25 @ViewChild('videoEdit', { static: false }) videoEditComponent: VideoEditComponent
26
24 @Output() firstStepDone = new EventEmitter<string>() 27 @Output() firstStepDone = new EventEmitter<string>()
25 @Output() firstStepError = new EventEmitter<void>() 28 @Output() firstStepError = new EventEmitter<void>()
26 29
@@ -41,6 +44,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
41 protected serverService: ServerService, 44 protected serverService: ServerService,
42 protected videoService: VideoService, 45 protected videoService: VideoService,
43 protected videoCaptionService: VideoCaptionService, 46 protected videoCaptionService: VideoCaptionService,
47 protected videoChapterService: VideoChapterService,
44 private router: Router, 48 private router: Router,
45 private videoImportService: VideoImportService, 49 private videoImportService: VideoImportService,
46 private hooks: HooksService 50 private hooks: HooksService
@@ -85,12 +89,13 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
85 switchMap(previous => { 89 switchMap(previous => {
86 return forkJoin([ 90 return forkJoin([
87 this.videoCaptionService.listCaptions(previous.video.uuid), 91 this.videoCaptionService.listCaptions(previous.video.uuid),
92 this.videoChapterService.getChapters({ videoId: previous.video.uuid }),
88 this.videoService.getVideo({ videoId: previous.video.uuid }) 93 this.videoService.getVideo({ videoId: previous.video.uuid })
89 ]).pipe(map(([ videoCaptionsResult, video ]) => ({ videoCaptions: videoCaptionsResult.data, video }))) 94 ]).pipe(map(([ videoCaptionsResult, { chapters }, video ]) => ({ videoCaptions: videoCaptionsResult.data, chapters, video })))
90 }) 95 })
91 ) 96 )
92 .subscribe({ 97 .subscribe({
93 next: ({ video, videoCaptions }) => { 98 next: ({ video, videoCaptions, chapters }) => {
94 this.loadingBar.useRef().complete() 99 this.loadingBar.useRef().complete()
95 this.firstStepDone.emit(video.name) 100 this.firstStepDone.emit(video.name)
96 this.isImportingVideo = false 101 this.isImportingVideo = false
@@ -99,9 +104,12 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
99 this.video = new VideoEdit(video) 104 this.video = new VideoEdit(video)
100 this.video.patch({ privacy: this.firstStepPrivacyId }) 105 this.video.patch({ privacy: this.firstStepPrivacyId })
101 106
107 this.chaptersEdit.loadFromAPI(chapters)
108
102 this.videoCaptions = videoCaptions 109 this.videoCaptions = videoCaptions
103 110
104 hydrateFormFromVideo(this.form, this.video, true) 111 hydrateFormFromVideo(this.form, this.video, true)
112 setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit))
105 }, 113 },
106 114
107 error: err => { 115 error: err => {
@@ -117,11 +125,12 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
117 if (!await this.isFormValid()) return 125 if (!await this.isFormValid()) return
118 126
119 this.video.patch(this.form.value) 127 this.video.patch(this.form.value)
128 this.chaptersEdit.patch(this.form.value)
120 129
121 this.isUpdatingVideo = true 130 this.isUpdatingVideo = true
122 131
123 // Update the video 132 // Update the video
124 this.updateVideoAndCaptions(this.video) 133 this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit })
125 .subscribe({ 134 .subscribe({
126 next: () => { 135 next: () => {
127 this.isUpdatingVideo = false 136 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'
4import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core' 4import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core'
5import { listUserChannelsForSelect } from '@app/helpers' 5import { listUserChannelsForSelect } from '@app/helpers'
6import { FormReactive } from '@app/shared/shared-forms' 6import { FormReactive } from '@app/shared/shared-forms'
7import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' 7import {
8 VideoCaptionEdit,
9 VideoCaptionService,
10 VideoChapterService,
11 VideoChaptersEdit,
12 VideoEdit,
13 VideoService
14} from '@app/shared/shared-main'
8import { LoadingBarService } from '@ngx-loading-bar/core' 15import { LoadingBarService } from '@ngx-loading-bar/core'
9import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models' 16import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models'
17import { of } from 'rxjs'
10 18
11@Directive() 19@Directive()
12// eslint-disable-next-line @angular-eslint/directive-class-suffix 20// eslint-disable-next-line @angular-eslint/directive-class-suffix
@@ -14,6 +22,7 @@ export abstract class VideoSend extends FormReactive implements OnInit {
14 userVideoChannels: SelectChannelItem[] = [] 22 userVideoChannels: SelectChannelItem[] = []
15 videoPrivacies: VideoConstant<VideoPrivacyType>[] = [] 23 videoPrivacies: VideoConstant<VideoPrivacyType>[] = []
16 videoCaptions: VideoCaptionEdit[] = [] 24 videoCaptions: VideoCaptionEdit[] = []
25 chaptersEdit = new VideoChaptersEdit()
17 26
18 firstStepPrivacyId: VideoPrivacyType 27 firstStepPrivacyId: VideoPrivacyType
19 firstStepChannelId: number 28 firstStepChannelId: number
@@ -28,6 +37,7 @@ export abstract class VideoSend extends FormReactive implements OnInit {
28 protected serverService: ServerService 37 protected serverService: ServerService
29 protected videoService: VideoService 38 protected videoService: VideoService
30 protected videoCaptionService: VideoCaptionService 39 protected videoCaptionService: VideoCaptionService
40 protected videoChapterService: VideoChapterService
31 41
32 protected serverConfig: HTMLServerConfig 42 protected serverConfig: HTMLServerConfig
33 43
@@ -60,13 +70,23 @@ export abstract class VideoSend extends FormReactive implements OnInit {
60 }) 70 })
61 } 71 }
62 72
63 protected updateVideoAndCaptions (video: VideoEdit) { 73 protected updateVideoAndCaptionsAndChapters (options: {
74 video: VideoEdit
75 captions: VideoCaptionEdit[]
76 chapters?: VideoChaptersEdit
77 }) {
78 const { video, captions, chapters } = options
79
64 this.loadingBar.useRef().start() 80 this.loadingBar.useRef().start()
65 81
66 return this.videoService.updateVideo(video) 82 return this.videoService.updateVideo(video)
67 .pipe( 83 .pipe(
68 // Then update captions 84 switchMap(() => this.videoCaptionService.updateCaptions(video.uuid, captions)),
69 switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions)), 85 switchMap(() => {
86 return chapters
87 ? this.videoChapterService.updateChapters(video.uuid, chapters)
88 : of(true)
89 }),
70 tap(() => this.loadingBar.useRef().complete()), 90 tap(() => this.loadingBar.useRef().complete()),
71 catchError(err => { 91 catchError(err => {
72 this.loadingBar.useRef().complete() 92 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'
7import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core' 7import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
8import { genericUploadErrorHandler, scrollToTop } from '@app/helpers' 8import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
9import { FormReactiveService } from '@app/shared/shared-forms' 9import { FormReactiveService } from '@app/shared/shared-forms'
10import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' 10import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main'
11import { LoadingBarService } from '@ngx-loading-bar/core' 11import { LoadingBarService } from '@ngx-loading-bar/core'
12import { logger } from '@root-helpers/logger' 12import { logger } from '@root-helpers/logger'
13import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models' 13import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models'
@@ -63,6 +63,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
63 protected serverService: ServerService, 63 protected serverService: ServerService,
64 protected videoService: VideoService, 64 protected videoService: VideoService,
65 protected videoCaptionService: VideoCaptionService, 65 protected videoCaptionService: VideoCaptionService,
66 protected videoChapterService: VideoChapterService,
66 private userService: UserService, 67 private userService: UserService,
67 private router: Router, 68 private router: Router,
68 private hooks: HooksService, 69 private hooks: HooksService,
@@ -241,9 +242,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
241 video.uuid = this.videoUploadedIds.uuid 242 video.uuid = this.videoUploadedIds.uuid
242 video.shortUUID = this.videoUploadedIds.shortUUID 243 video.shortUUID = this.videoUploadedIds.shortUUID
243 244
245 this.chaptersEdit.patch(this.form.value)
246
244 this.isUpdatingVideo = true 247 this.isUpdatingVideo = true
245 248
246 this.updateVideoAndCaptions(video) 249 this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions, chapters: this.chaptersEdit })
247 .subscribe({ 250 .subscribe({
248 next: () => { 251 next: () => {
249 this.isUpdatingVideo = false 252 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 @@
13 <form novalidate [formGroup]="form"> 13 <form novalidate [formGroup]="form">
14 14
15 <my-video-edit 15 <my-video-edit
16 #videoEdit
16 [form]="form" [formErrors]="formErrors" [forbidScheduledPublication]="forbidScheduledPublication" 17 [form]="form" [formErrors]="formErrors" [forbidScheduledPublication]="forbidScheduledPublication"
17 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" 18 [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
18 [videoCaptions]="videoCaptions" [hideWaitTranscoding]="isWaitTranscodingHidden()" 19 [videoCaptions]="videoCaptions" [hideWaitTranscoding]="isWaitTranscodingHidden()"
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts
index ea2f76d71..82f45f73d 100644
--- a/client/src/app/+videos/+video-edit/video-update.component.ts
+++ b/client/src/app/+videos/+video-edit/video-update.component.ts
@@ -4,18 +4,28 @@ import { of, Subject, Subscription } from 'rxjs'
4import { catchError, map, switchMap } from 'rxjs/operators' 4import { catchError, map, switchMap } from 'rxjs/operators'
5import { SelectChannelItem } from 'src/types/select-options-item.model' 5import { SelectChannelItem } from 'src/types/select-options-item.model'
6import { HttpErrorResponse } from '@angular/common/http' 6import { HttpErrorResponse } from '@angular/common/http'
7import { Component, HostListener, OnDestroy, OnInit } from '@angular/core' 7import { Component, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core'
8import { ActivatedRoute, Router } from '@angular/router' 8import { ActivatedRoute, Router } from '@angular/router'
9import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core' 9import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core'
10import { genericUploadErrorHandler } from '@app/helpers' 10import { genericUploadErrorHandler } from '@app/helpers'
11import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 11import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
12import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' 12import {
13 Video,
14 VideoCaptionEdit,
15 VideoCaptionService,
16 VideoChapterService,
17 VideoChaptersEdit,
18 VideoDetails,
19 VideoEdit,
20 VideoService
21} from '@app/shared/shared-main'
13import { LiveVideoService } from '@app/shared/shared-video-live' 22import { LiveVideoService } from '@app/shared/shared-video-live'
14import { LoadingBarService } from '@ngx-loading-bar/core' 23import { LoadingBarService } from '@ngx-loading-bar/core'
15import { pick, simpleObjectsDeepEqual } from '@peertube/peertube-core-utils' 24import { pick, simpleObjectsDeepEqual } from '@peertube/peertube-core-utils'
16import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoSource, VideoState } from '@peertube/peertube-models' 25import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoSource, VideoState } from '@peertube/peertube-models'
17import { hydrateFormFromVideo } from './shared/video-edit-utils' 26import { hydrateFormFromVideo } from './shared/video-edit-utils'
18import { VideoUploadService } from './shared/video-upload.service' 27import { VideoUploadService } from './shared/video-upload.service'
28import { VideoEditComponent } from './shared/video-edit.component'
19 29
20const debugLogger = debug('peertube:video-update') 30const debugLogger = debug('peertube:video-update')
21 31
@@ -25,6 +35,8 @@ const debugLogger = debug('peertube:video-update')
25 templateUrl: './video-update.component.html' 35 templateUrl: './video-update.component.html'
26}) 36})
27export class VideoUpdateComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate { 37export class VideoUpdateComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate {
38 @ViewChild('videoEdit', { static: false }) videoEditComponent: VideoEditComponent
39
28 videoEdit: VideoEdit 40 videoEdit: VideoEdit
29 videoDetails: VideoDetails 41 videoDetails: VideoDetails
30 videoSource: VideoSource 42 videoSource: VideoSource
@@ -50,6 +62,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
50 private uploadServiceSubscription: Subscription 62 private uploadServiceSubscription: Subscription
51 private updateSubcription: Subscription 63 private updateSubcription: Subscription
52 64
65 private chaptersEdit = new VideoChaptersEdit()
66
53 constructor ( 67 constructor (
54 protected formReactiveService: FormReactiveService, 68 protected formReactiveService: FormReactiveService,
55 private route: ActivatedRoute, 69 private route: ActivatedRoute,
@@ -58,6 +72,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
58 private videoService: VideoService, 72 private videoService: VideoService,
59 private loadingBar: LoadingBarService, 73 private loadingBar: LoadingBarService,
60 private videoCaptionService: VideoCaptionService, 74 private videoCaptionService: VideoCaptionService,
75 private videoChapterService: VideoChapterService,
61 private server: ServerService, 76 private server: ServerService,
62 private liveVideoService: LiveVideoService, 77 private liveVideoService: LiveVideoService,
63 private videoUploadService: VideoUploadService, 78 private videoUploadService: VideoUploadService,
@@ -84,10 +99,11 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
84 .subscribe(state => this.onUploadVideoOngoing(state)) 99 .subscribe(state => this.onUploadVideoOngoing(state))
85 100
86 const { videoData } = this.route.snapshot.data 101 const { videoData } = this.route.snapshot.data
87 const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData 102 const { video, videoChannels, videoCaptions, videoChapters, videoSource, liveVideo, videoPassword } = videoData
88 103
89 this.videoDetails = video 104 this.videoDetails = video
90 this.videoEdit = new VideoEdit(this.videoDetails, videoPassword) 105 this.videoEdit = new VideoEdit(this.videoDetails, videoPassword)
106 this.chaptersEdit.loadFromAPI(videoChapters)
91 107
92 this.userVideoChannels = videoChannels 108 this.userVideoChannels = videoChannels
93 this.videoCaptions = videoCaptions 109 this.videoCaptions = videoCaptions
@@ -106,6 +122,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
106 onFormBuilt () { 122 onFormBuilt () {
107 hydrateFormFromVideo(this.form, this.videoEdit, true) 123 hydrateFormFromVideo(this.form, this.videoEdit, true)
108 124
125 setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit))
126
109 if (this.liveVideo) { 127 if (this.liveVideo) {
110 this.form.patchValue({ 128 this.form.patchValue({
111 saveReplay: this.liveVideo.saveReplay, 129 saveReplay: this.liveVideo.saveReplay,
@@ -172,6 +190,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
172 if (!await this.checkAndConfirmVideoFileReplacement()) return 190 if (!await this.checkAndConfirmVideoFileReplacement()) return
173 191
174 this.videoEdit.patch(this.form.value) 192 this.videoEdit.patch(this.form.value)
193 this.chaptersEdit.patch(this.form.value)
175 194
176 this.abortUpdateIfNeeded() 195 this.abortUpdateIfNeeded()
177 196
@@ -180,10 +199,12 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
180 199
181 this.updateSubcription = this.videoReplacementUploadedSubject.pipe( 200 this.updateSubcription = this.videoReplacementUploadedSubject.pipe(
182 switchMap(() => this.videoService.updateVideo(this.videoEdit)), 201 switchMap(() => this.videoService.updateVideo(this.videoEdit)),
202 switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.uuid, this.videoCaptions)),
203 switchMap(() => {
204 if (this.liveVideo) return of(true)
183 205
184 // Then update captions 206 return this.videoChapterService.updateChapters(this.videoEdit.uuid, this.chaptersEdit)
185 switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)), 207 }),
186
187 switchMap(() => { 208 switchMap(() => {
188 if (!this.liveVideo) return of(undefined) 209 if (!this.liveVideo) return of(undefined)
189 210
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'
4import { ActivatedRouteSnapshot } from '@angular/router' 4import { ActivatedRouteSnapshot } from '@angular/router'
5import { AuthService } from '@app/core' 5import { AuthService } from '@app/core'
6import { listUserChannelsForSelect } from '@app/helpers' 6import { listUserChannelsForSelect } from '@app/helpers'
7import { VideoCaptionService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main' 7import { VideoCaptionService, VideoChapterService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main'
8import { LiveVideoService } from '@app/shared/shared-video-live' 8import { LiveVideoService } from '@app/shared/shared-video-live'
9import { VideoPrivacy } from '@peertube/peertube-models' 9import { VideoPrivacy } from '@peertube/peertube-models'
10 10
@@ -15,6 +15,7 @@ export class VideoUpdateResolver {
15 private liveVideoService: LiveVideoService, 15 private liveVideoService: LiveVideoService,
16 private authService: AuthService, 16 private authService: AuthService,
17 private videoCaptionService: VideoCaptionService, 17 private videoCaptionService: VideoCaptionService,
18 private videoChapterService: VideoChapterService,
18 private videoPasswordService: VideoPasswordService 19 private videoPasswordService: VideoPasswordService
19 ) { 20 ) {
20 } 21 }
@@ -25,8 +26,8 @@ export class VideoUpdateResolver {
25 return this.videoService.getVideo({ videoId: uuid }) 26 return this.videoService.getVideo({ videoId: uuid })
26 .pipe( 27 .pipe(
27 switchMap(video => forkJoin(this.buildVideoObservables(video))), 28 switchMap(video => forkJoin(this.buildVideoObservables(video))),
28 map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) => 29 map(([ video, videoSource, videoChannels, videoCaptions, videoChapters, liveVideo, videoPassword ]) =>
29 ({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword })) 30 ({ video, videoChannels, videoCaptions, videoChapters, videoSource, liveVideo, videoPassword }))
30 ) 31 )
31 } 32 }
32 33
@@ -46,6 +47,12 @@ export class VideoUpdateResolver {
46 map(result => result.data) 47 map(result => result.data)
47 ), 48 ),
48 49
50 this.videoChapterService
51 .getChapters({ videoId: video.uuid })
52 .pipe(
53 map(({ chapters }) => chapters)
54 ),
55
49 video.isLive 56 video.isLive
50 ? this.liveVideoService.getVideoLive(video.id) 57 ? this.liveVideoService.getVideoLive(video.id)
51 : of(undefined), 58 : 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 {
18} from '@app/core' 18} from '@app/core'
19import { HooksService } from '@app/core/plugins/hooks.service' 19import { HooksService } from '@app/core/plugins/hooks.service'
20import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers' 20import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers'
21import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' 21import { Video, VideoCaptionService, VideoChapterService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main'
22import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' 22import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
23import { LiveVideoService } from '@app/shared/shared-video-live' 23import { LiveVideoService } from '@app/shared/shared-video-live'
24import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' 24import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
@@ -31,6 +31,7 @@ import {
31 ServerErrorCode, 31 ServerErrorCode,
32 Storyboard, 32 Storyboard,
33 VideoCaption, 33 VideoCaption,
34 VideoChapter,
34 VideoPrivacy, 35 VideoPrivacy,
35 VideoState, 36 VideoState,
36 VideoStateType 37 VideoStateType
@@ -83,6 +84,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
83 84
84 video: VideoDetails = null 85 video: VideoDetails = null
85 videoCaptions: VideoCaption[] = [] 86 videoCaptions: VideoCaption[] = []
87 videoChapters: VideoChapter[] = []
86 liveVideo: LiveVideo 88 liveVideo: LiveVideo
87 videoPassword: string 89 videoPassword: string
88 storyboards: Storyboard[] = [] 90 storyboards: Storyboard[] = []
@@ -125,6 +127,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
125 private notifier: Notifier, 127 private notifier: Notifier,
126 private zone: NgZone, 128 private zone: NgZone,
127 private videoCaptionService: VideoCaptionService, 129 private videoCaptionService: VideoCaptionService,
130 private videoChapterService: VideoChapterService,
128 private hotkeysService: HotkeysService, 131 private hotkeysService: HotkeysService,
129 private hooks: HooksService, 132 private hooks: HooksService,
130 private pluginService: PluginService, 133 private pluginService: PluginService,
@@ -306,14 +309,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
306 forkJoin([ 309 forkJoin([
307 videoAndLiveObs, 310 videoAndLiveObs,
308 this.videoCaptionService.listCaptions(videoId, videoPassword), 311 this.videoCaptionService.listCaptions(videoId, videoPassword),
312 this.videoChapterService.getChapters({ videoId, videoPassword }),
309 this.videoService.getStoryboards(videoId, videoPassword), 313 this.videoService.getStoryboards(videoId, videoPassword),
310 this.userService.getAnonymousOrLoggedUser() 314 this.userService.getAnonymousOrLoggedUser()
311 ]).subscribe({ 315 ]).subscribe({
312 next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => { 316 next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, loggedInOrAnonymousUser ]) => {
313 this.onVideoFetched({ 317 this.onVideoFetched({
314 video, 318 video,
315 live, 319 live,
316 videoCaptions: captionsResult.data, 320 videoCaptions: captionsResult.data,
321 videoChapters: chaptersResult.chapters,
317 storyboards, 322 storyboards,
318 videoFileToken, 323 videoFileToken,
319 videoPassword, 324 videoPassword,
@@ -411,6 +416,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
411 video: VideoDetails 416 video: VideoDetails
412 live: LiveVideo 417 live: LiveVideo
413 videoCaptions: VideoCaption[] 418 videoCaptions: VideoCaption[]
419 videoChapters: VideoChapter[]
414 storyboards: Storyboard[] 420 storyboards: Storyboard[]
415 videoFileToken: string 421 videoFileToken: string
416 videoPassword: string 422 videoPassword: string
@@ -422,6 +428,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
422 video, 428 video,
423 live, 429 live,
424 videoCaptions, 430 videoCaptions,
431 videoChapters,
425 storyboards, 432 storyboards,
426 videoFileToken, 433 videoFileToken,
427 videoPassword, 434 videoPassword,
@@ -433,6 +440,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
433 440
434 this.video = video 441 this.video = video
435 this.videoCaptions = videoCaptions 442 this.videoCaptions = videoCaptions
443 this.videoChapters = videoChapters
436 this.liveVideo = live 444 this.liveVideo = live
437 this.videoFileToken = videoFileToken 445 this.videoFileToken = videoFileToken
438 this.videoPassword = videoPassword 446 this.videoPassword = videoPassword
@@ -480,6 +488,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
480 const params = { 488 const params = {
481 video: this.video, 489 video: this.video,
482 videoCaptions: this.videoCaptions, 490 videoCaptions: this.videoCaptions,
491 videoChapters: this.videoChapters,
483 storyboards: this.storyboards, 492 storyboards: this.storyboards,
484 liveVideo: this.liveVideo, 493 liveVideo: this.liveVideo,
485 videoFileToken: this.videoFileToken, 494 videoFileToken: this.videoFileToken,
@@ -636,6 +645,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
636 video: VideoDetails 645 video: VideoDetails
637 liveVideo: LiveVideo 646 liveVideo: LiveVideo
638 videoCaptions: VideoCaption[] 647 videoCaptions: VideoCaption[]
648 videoChapters: VideoChapter[]
639 storyboards: Storyboard[] 649 storyboards: Storyboard[]
640 650
641 videoFileToken: string 651 videoFileToken: string
@@ -651,6 +661,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
651 video, 661 video,
652 liveVideo, 662 liveVideo,
653 videoCaptions, 663 videoCaptions,
664 videoChapters,
654 storyboards, 665 storyboards,
655 videoFileToken, 666 videoFileToken,
656 videoPassword, 667 videoPassword,
@@ -750,6 +761,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
750 videoPassword: () => videoPassword, 761 videoPassword: () => videoPassword,
751 762
752 videoCaptions: playerCaptions, 763 videoCaptions: playerCaptions,
764 videoChapters,
753 storyboard, 765 storyboard,
754 766
755 videoShortUUID: video.shortUUID, 767 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 <T> (arr: T[], elem: T) {
7 if (index !== -1) arr.splice(index, 1) 7 if (index !== -1) arr.splice(index, 1)
8} 8}
9 9
10function sortBy (obj: any[], key1: string, key2?: string) {
11 return obj.sort((a, b) => {
12 const elem1 = key2 ? a[key1][key2] : a[key1]
13 const elem2 = key2 ? b[key1][key2] : b[key1]
14
15 if (elem1 < elem2) return -1
16 if (elem1 === elem2) return 0
17 return 1
18 })
19}
20
21function splitIntoArray (value: any) { 10function splitIntoArray (value: any) {
22 if (!value) return undefined 11 if (!value) return undefined
23 if (Array.isArray(value)) return value 12 if (Array.isArray(value)) return value
@@ -41,7 +30,6 @@ function toBoolean (value: any) {
41} 30}
42 31
43export { 32export {
44 sortBy,
45 immutableAssign, 33 immutableAssign,
46 removeElementFromArray, 34 removeElementFromArray,
47 splitIntoArray, 35 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 @@
1import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' 1import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
2import { getDevLocale, isOnDevLocale, sortBy } from '@app/helpers' 2import { getDevLocale, isOnDevLocale } from '@app/helpers'
3import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 3import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped } from '@peertube/peertube-core-utils' 4import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped, sortBy } from '@peertube/peertube-core-utils'
5 5
6@Component({ 6@Component({
7 selector: 'my-language-chooser', 7 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 @@
1import { AbstractControl, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'
2import { BuildFormValidator } from './form-validator.model'
3
4export const VIDEO_CHAPTER_TITLE_VALIDATOR: BuildFormValidator = {
5 VALIDATORS: [ Validators.minLength(2), Validators.maxLength(100) ], // Required is set dynamically
6 MESSAGES: {
7 required: $localize`A chapter title is required.`,
8 minlength: $localize`A chapter title should be more than 2 characters long.`,
9 maxlength: $localize`A chapter title should be less than 100 characters long.`
10 }
11}
12
13export const VIDEO_CHAPTERS_ARRAY_VALIDATOR: BuildFormValidator = {
14 VALIDATORS: [ uniqueTimecodeValidator() ],
15 MESSAGES: {}
16}
17
18function uniqueTimecodeValidator (): ValidatorFn {
19 return (control: AbstractControl): ValidationErrors => {
20 const array = control.value as { timecode: number, title: string }[]
21
22 for (const chapter of array) {
23 if (!chapter.title) continue
24
25 if (array.filter(c => c.title && c.timecode === chapter.timecode).length > 1) {
26 return { uniqueTimecode: $localize`Multiple chapters have the same timecode ${chapter.timecode}` }
27 }
28 }
29
30 return null
31 }
32}
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 = {
70 } 70 }
71} 71}
72 72
73export const VIDEO_TAG_VALIDATOR: BuildFormValidator = {
74 VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ],
75 MESSAGES: {
76 minlength: $localize`A tag should be more than 2 characters long.`,
77 maxlength: $localize`A tag should be less than 30 characters long.`
78 }
79}
80
81export const VIDEO_TAGS_ARRAY_VALIDATOR: BuildFormValidator = { 73export const VIDEO_TAGS_ARRAY_VALIDATOR: BuildFormValidator = {
82 VALIDATORS: [ Validators.maxLength(5), arrayTagLengthValidator() ], 74 VALIDATORS: [ Validators.maxLength(5), arrayTagLengthValidator() ],
83 MESSAGES: { 75 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'
4import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' 4import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
5import { FormValidatorService } from './form-validator.service' 5import { FormValidatorService } from './form-validator.service'
6 6
7export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors } 7export type FormReactiveErrors = { [ id: string | number ]: string | FormReactiveErrors | FormReactiveErrors[] }
8export type FormReactiveValidationMessages = { 8export type FormReactiveValidationMessages = {
9 [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages 9 [ id: string | number ]: { [ name: string ]: string } | FormReactiveValidationMessages | FormReactiveValidationMessages[]
10} 10}
11 11
12@Injectable() 12@Injectable()
@@ -86,7 +86,7 @@ export class FormReactiveService {
86 86
87 if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue 87 if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue
88 88
89 const staticMessages = validationMessages[field] 89 const staticMessages = validationMessages[field] as FormReactiveValidationMessages
90 for (const key of Object.keys(control.errors)) { 90 for (const key of Object.keys(control.errors)) {
91 const formErrorValue = control.errors[key] 91 const formErrorValue = control.errors[key]
92 92
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 {
45 form: FormGroup, 45 form: FormGroup,
46 formErrors: FormReactiveErrors, 46 formErrors: FormReactiveErrors,
47 validationMessages: FormReactiveValidationMessages, 47 validationMessages: FormReactiveValidationMessages,
48 obj: BuildFormArgument, 48 formToBuild: BuildFormArgument,
49 defaultValues: BuildFormDefaultValues = {} 49 defaultValues: BuildFormDefaultValues = {}
50 ) { 50 ) {
51 for (const name of objectKeysTyped(obj)) { 51 for (const name of objectKeysTyped(formToBuild)) {
52 formErrors[name] = '' 52 formErrors[name] = ''
53 53
54 const field = obj[name] 54 const field = formToBuild[name]
55 if (this.isRecursiveField(field)) { 55 if (this.isRecursiveField(field)) {
56 this.updateFormGroup( 56 this.updateFormGroup(
57 // FIXME: typings 57 // FIXME: typings
58 (form as any)[name], 58 (form as any)[name],
59 formErrors[name] as FormReactiveErrors, 59 formErrors[name] as FormReactiveErrors,
60 validationMessages[name] as FormReactiveValidationMessages, 60 validationMessages[name] as FormReactiveValidationMessages,
61 obj[name] as BuildFormArgument, 61 formToBuild[name] as BuildFormArgument,
62 defaultValues[name] as BuildFormDefaultValues 62 defaultValues[name] as BuildFormDefaultValues
63 ) 63 )
64 continue 64 continue
@@ -66,7 +66,7 @@ export class FormValidatorService {
66 66
67 if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } 67 if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
68 68
69 const defaultValue = defaultValues[name] || '' 69 const defaultValue = defaultValues[name] ?? ''
70 70
71 form.addControl( 71 form.addControl(
72 name + '', 72 name + '',
@@ -75,6 +75,55 @@ export class FormValidatorService {
75 } 75 }
76 } 76 }
77 77
78 addControlInFormArray (options: {
79 formErrors: FormReactiveErrors
80 validationMessages: FormReactiveValidationMessages
81 formArray: FormArray
82 controlName: string
83 formToBuild: BuildFormArgument
84 defaultValues?: BuildFormDefaultValues
85 }) {
86 const { formArray, formErrors, validationMessages, controlName, formToBuild, defaultValues = {} } = options
87
88 const formGroup = new FormGroup({})
89 if (!formErrors[controlName]) formErrors[controlName] = [] as FormReactiveErrors[]
90 if (!validationMessages[controlName]) validationMessages[controlName] = []
91
92 const formArrayErrors = formErrors[controlName] as FormReactiveErrors[]
93 const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[]
94
95 const totalControls = formArray.controls.length
96 formArrayErrors.push({})
97 formArrayValidationMessages.push({})
98
99 this.updateFormGroup(
100 formGroup,
101 formArrayErrors[totalControls],
102 formArrayValidationMessages[totalControls],
103 formToBuild,
104 defaultValues
105 )
106
107 formArray.push(formGroup)
108 }
109
110 removeControlFromFormArray (options: {
111 formErrors: FormReactiveErrors
112 validationMessages: FormReactiveValidationMessages
113 index: number
114 formArray: FormArray
115 controlName: string
116 }) {
117 const { formArray, formErrors, validationMessages, index, controlName } = options
118
119 const formArrayErrors = formErrors[controlName] as FormReactiveErrors[]
120 const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[]
121
122 formArrayErrors.splice(index, 1)
123 formArrayValidationMessages.splice(index, 1)
124 formArray.removeAt(index)
125 }
126
78 updateTreeValidity (group: FormGroup | FormArray): void { 127 updateTreeValidity (group: FormGroup | FormArray): void {
79 for (const key of Object.keys(group.controls)) { 128 for (const key of Object.keys(group.controls)) {
80 // FIXME: typings 129 // 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 @@
1import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core' 1import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { Notifier } from '@app/core' 3import { FormReactiveErrors } from './form-reactive.service'
4 4
5@Component({ 5@Component({
6 selector: 'my-input-text', 6 selector: 'my-input-text',
@@ -26,9 +26,7 @@ export class InputTextComponent implements ControlValueAccessor {
26 @Input() withCopy = false 26 @Input() withCopy = false
27 @Input() readonly = false 27 @Input() readonly = false
28 @Input() show = false 28 @Input() show = false
29 @Input() formError: string 29 @Input() formError: string | FormReactiveErrors | FormReactiveErrors[]
30
31 constructor (private notifier: Notifier) { }
32 30
33 get inputType () { 31 get inputType () {
34 return this.show 32 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 @@
25 </ng-template> 25 </ng-template>
26 </ng-container> 26 </ng-container>
27 27
28 <button (click)="onMaximizeClick()" class="maximize-button border-0 m-3" [disabled]="disabled"> 28 <button type="button" (click)="onMaximizeClick()" class="maximize-button border-0 m-3" [disabled]="disabled">
29 <my-global-icon *ngIf="!isMaximized" [ngbTooltip]="maximizeInText" iconName="fullscreen"></my-global-icon> 29 <my-global-icon *ngIf="!isMaximized" [ngbTooltip]="maximizeInText" iconName="fullscreen"></my-global-icon>
30 30
31 <my-global-icon *ngIf="isMaximized" [ngbTooltip]="maximizeOutText" iconName="exit-fullscreen"></my-global-icon> 31 <my-global-icon *ngIf="isMaximized" [ngbTooltip]="maximizeOutText" iconName="exit-fullscreen"></my-global-icon>
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.ts b/client/src/app/shared/shared-forms/markdown-textarea.component.ts
index 169be39d1..77e6cbd8c 100644
--- a/client/src/app/shared/shared-forms/markdown-textarea.component.ts
+++ b/client/src/app/shared/shared-forms/markdown-textarea.component.ts
@@ -6,6 +6,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
6import { SafeHtml } from '@angular/platform-browser' 6import { SafeHtml } from '@angular/platform-browser'
7import { MarkdownService, ScreenService } from '@app/core' 7import { MarkdownService, ScreenService } from '@app/core'
8import { Video } from '@peertube/peertube-models' 8import { Video } from '@peertube/peertube-models'
9import { FormReactiveErrors } from './form-reactive.service'
9 10
10@Component({ 11@Component({
11 selector: 'my-markdown-textarea', 12 selector: 'my-markdown-textarea',
@@ -23,7 +24,7 @@ import { Video } from '@peertube/peertube-models'
23export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { 24export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
24 @Input() content = '' 25 @Input() content = ''
25 26
26 @Input() formError: string 27 @Input() formError: string | FormReactiveErrors | FormReactiveErrors[]
27 28
28 @Input() truncateTo3Lines: boolean 29 @Input() truncateTo3Lines: boolean
29 30
diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.scss b/client/src/app/shared/shared-forms/timestamp-input.component.scss
index e69a06947..df19240b4 100644
--- a/client/src/app/shared/shared-forms/timestamp-input.component.scss
+++ b/client/src/app/shared/shared-forms/timestamp-input.component.scss
@@ -4,6 +4,7 @@
4p-inputmask { 4p-inputmask {
5 ::ng-deep input { 5 ::ng-deep input {
6 width: 80px; 6 width: 80px;
7 text-align: center;
7 8
8 &:focus-within, 9 &:focus-within,
9 &:focus { 10 &:focus {
diff --git a/client/src/app/shared/shared-main/buttons/button.component.html b/client/src/app/shared/shared-main/buttons/button.component.html
index d87e35876..9270c0925 100644
--- a/client/src/app/shared/shared-main/buttons/button.component.html
+++ b/client/src/app/shared/shared-main/buttons/button.component.html
@@ -1,4 +1,4 @@
1<button *ngIf="!ptRouterLink" class="action-button" [ngClass]="classes" [ngbTooltip]="title"> 1<button *ngIf="!ptRouterLink" type="button" class="action-button" [ngClass]="classes" [ngbTooltip]="title">
2 <ng-container *ngTemplateOutlet="content"></ng-container> 2 <ng-container *ngTemplateOutlet="content"></ng-container>
3</button> 3</button>
4 4
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
49import { 49import {
50 EmbedComponent, 50 EmbedComponent,
51 RedundancyService, 51 RedundancyService,
52 VideoChapterService,
52 VideoFileTokenService, 53 VideoFileTokenService,
53 VideoImportService, 54 VideoImportService,
54 VideoOwnershipService, 55 VideoOwnershipService,
@@ -215,6 +216,8 @@ import { VideoChannelService } from './video-channel'
215 216
216 VideoPasswordService, 217 VideoPasswordService,
217 218
219 VideoChapterService,
220
218 CustomPageService, 221 CustomPageService,
219 222
220 ActorRedirectGuard 223 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'
3import { HttpClient } from '@angular/common/http' 3import { HttpClient } from '@angular/common/http'
4import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
5import { RestExtractor, ServerService } from '@app/core' 5import { RestExtractor, ServerService } from '@app/core'
6import { objectToFormData, sortBy } from '@app/helpers' 6import { objectToFormData } from '@app/helpers'
7import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video' 7import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video'
8import { peertubeTranslate } from '@peertube/peertube-core-utils' 8import { peertubeTranslate, sortBy } from '@peertube/peertube-core-utils'
9import { ResultList, VideoCaption } from '@peertube/peertube-models' 9import { ResultList, VideoCaption } from '@peertube/peertube-models'
10import { environment } from '../../../../environments/environment' 10import { environment } from '../../../../environments/environment'
11import { VideoCaptionEdit } from './video-caption-edit.model' 11import { 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 @@
1export * from './embed.component' 1export * from './embed.component'
2export * from './redundancy.service' 2export * from './redundancy.service'
3export * from './video-chapter.service'
4export * from './video-chapters-edit.model'
3export * from './video-details.model' 5export * from './video-details.model'
4export * from './video-edit.model' 6export * from './video-edit.model'
5export * from './video-file-token.service' 7export * 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 @@
1import { catchError } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/core'
5import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models'
6import { VideoPasswordService } from './video-password.service'
7import { VideoService } from './video.service'
8import { VideoChaptersEdit } from './video-chapters-edit.model'
9import { of } from 'rxjs'
10
11@Injectable()
12export class VideoChapterService {
13
14 constructor (
15 private authHttp: HttpClient,
16 private restExtractor: RestExtractor
17 ) {}
18
19 getChapters (options: { videoId: string, videoPassword?: string }) {
20 const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword)
21
22 return this.authHttp.get<{ chapters: VideoChapter[] }>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}/chapters`, { headers })
23 .pipe(catchError(err => this.restExtractor.handleError(err)))
24 }
25
26 updateChapters (videoId: string, chaptersEdit: VideoChaptersEdit) {
27 if (chaptersEdit.shouldUpdateAPI() !== true) return of(true)
28
29 const body = { chapters: chaptersEdit.getChaptersForUpdate() } as VideoChapterUpdate
30
31 return this.authHttp.put(`${VideoService.BASE_VIDEO_URL}/${videoId}/chapters`, body)
32 .pipe(catchError(err => this.restExtractor.handleError(err)))
33 }
34}
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 @@
1import { simpleObjectsDeepEqual, sortBy } from '@peertube/peertube-core-utils'
2import { VideoChapter } from '@peertube/peertube-models'
3
4export class VideoChaptersEdit {
5 private chaptersFromAPI: VideoChapter[] = []
6
7 private chapters: VideoChapter[]
8
9 loadFromAPI (chapters: VideoChapter[]) {
10 this.chapters = chapters || []
11
12 this.chaptersFromAPI = chapters
13 }
14
15 patch (values: { [ id: string ]: any }) {
16 const chapters = values.chapters || []
17
18 this.chapters = chapters.map((c: any) => {
19 return {
20 timecode: c.timecode || 0,
21 title: c.title
22 }
23 })
24 }
25
26 toFormPatch () {
27 return { chapters: this.chapters }
28 }
29
30 getChaptersForUpdate (): VideoChapter[] {
31 return this.chapters.filter(c => !!c.title)
32 }
33
34 hasDuplicateValues () {
35 const timecodes = this.chapters.map(c => c.timecode)
36
37 return new Set(timecodes).size !== this.chapters.length
38 }
39
40 shouldUpdateAPI () {
41 return simpleObjectsDeepEqual(sortBy(this.getChaptersForUpdate(), 'timecode'), this.chaptersFromAPI) !== true
42 }
43}
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'
7import './shared/peertube/peertube-plugin' 7import './shared/peertube/peertube-plugin'
8import './shared/resolutions/peertube-resolutions-plugin' 8import './shared/resolutions/peertube-resolutions-plugin'
9import './shared/control-bar/storyboard-plugin' 9import './shared/control-bar/storyboard-plugin'
10import './shared/control-bar/chapters-plugin'
11import './shared/control-bar/time-tooltip'
10import './shared/control-bar/next-previous-video-button' 12import './shared/control-bar/next-previous-video-button'
11import './shared/control-bar/p2p-info-button' 13import './shared/control-bar/p2p-info-button'
12import './shared/control-bar/peertube-link-button' 14import './shared/control-bar/peertube-link-button'
@@ -227,6 +229,7 @@ export class PeerTubePlayer {
227 if (this.player.usingPlugin('upnext')) this.player.upnext().dispose() 229 if (this.player.usingPlugin('upnext')) this.player.upnext().dispose()
228 if (this.player.usingPlugin('stats')) this.player.stats().dispose() 230 if (this.player.usingPlugin('stats')) this.player.stats().dispose()
229 if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose() 231 if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose()
232 if (this.player.usingPlugin('chapters')) this.player.chapters().dispose()
230 233
231 if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose() 234 if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose()
232 235
@@ -273,6 +276,10 @@ export class PeerTubePlayer {
273 this.player.storyboard(this.currentLoadOptions.storyboard) 276 this.player.storyboard(this.currentLoadOptions.storyboard)
274 } 277 }
275 278
279 if (this.currentLoadOptions.videoChapters) {
280 this.player.chapters({ chapters: this.currentLoadOptions.videoChapters })
281 }
282
276 if (this.currentLoadOptions.dock) { 283 if (this.currentLoadOptions.dock) {
277 this.player.peertubeDock(this.currentLoadOptions.dock) 284 this.player.peertubeDock(this.currentLoadOptions.dock)
278 } 285 }
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 @@
1import videojs from 'video.js'
2import { ChaptersOptions } from '../../types'
3import { VideoChapter } from '@peertube/peertube-models'
4import { ProgressBarMarkerComponent } from './progress-bar-marker-component'
5
6const Plugin = videojs.getPlugin('plugin')
7
8class ChaptersPlugin extends Plugin {
9 private chapters: VideoChapter[] = []
10 private markers: ProgressBarMarkerComponent[] = []
11
12 constructor (player: videojs.Player, options: videojs.ComponentOptions & ChaptersOptions) {
13 super(player, options)
14
15 this.chapters = options.chapters
16
17 this.player.ready(() => {
18 player.addClass('vjs-chapters')
19
20 this.player.one('durationchange', () => {
21 for (const chapter of this.chapters) {
22 if (chapter.timecode === 0) continue
23
24 const marker = new ProgressBarMarkerComponent(player, { timecode: chapter.timecode })
25
26 this.markers.push(marker)
27 this.getSeekBar().addChild(marker)
28 }
29 })
30 })
31 }
32
33 dispose () {
34 for (const marker of this.markers) {
35 this.getSeekBar().removeChild(marker)
36 }
37 }
38
39 getChapter (timecode: number) {
40 if (this.chapters.length !== 0) {
41 for (let i = this.chapters.length - 1; i >= 0; i--) {
42 const chapter = this.chapters[i]
43
44 if (chapter.timecode <= timecode) {
45 this.player.addClass('has-chapter')
46
47 return chapter.title
48 }
49 }
50 }
51
52 this.player.removeClass('has-chapter')
53
54 return ''
55 }
56
57 private getSeekBar () {
58 return this.player.getDescendant('ControlBar', 'ProgressControl', 'SeekBar')
59 }
60}
61
62videojs.registerPlugin('chapters', ChaptersPlugin)
63
64export { 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 @@
1export * from './chapters-plugin'
1export * from './next-previous-video-button' 2export * from './next-previous-video-button'
2export * from './p2p-info-button' 3export * from './p2p-info-button'
3export * from './peertube-link-button' 4export * from './peertube-link-button'
4export * from './peertube-live-display' 5export * from './peertube-live-display'
5export * from './storyboard-plugin' 6export * from './storyboard-plugin'
6export * from './theater-button' 7export * from './theater-button'
8export * 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 @@
1import videojs from 'video.js'
2import { ProgressBarMarkerComponentOptions } from '../../types'
3
4const Component = videojs.getComponent('Component')
5
6export class ProgressBarMarkerComponent extends Component {
7 options_: ProgressBarMarkerComponentOptions & videojs.ComponentOptions
8
9 // eslint-disable-next-line @typescript-eslint/no-useless-constructor
10 constructor (player: videojs.Player, options?: ProgressBarMarkerComponentOptions & videojs.ComponentOptions) {
11 super(player, options)
12 }
13
14 createEl () {
15 const left = (this.options_.timecode / this.player().duration()) * 100
16
17 return videojs.dom.createEl('span', {
18 className: 'vjs-marker',
19 style: `left: ${left}%`
20 }) as HTMLButtonElement
21 }
22}
23
24videojs.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 {
141 const ctop = Math.floor(position / columns) * -scaledHeight 141 const ctop = Math.floor(position / columns) * -scaledHeight
142 142
143 const bgSize = `${imgWidth * scaleFactor}px ${imgHeight * scaleFactor}px` 143 const bgSize = `${imgWidth * scaleFactor}px ${imgHeight * scaleFactor}px`
144 const topOffset = -scaledHeight - 60 144
145 const timeTooltip = this.player.el().querySelector('.vjs-time-tooltip')
146 const topOffset = -scaledHeight + parseInt(getComputedStyle(timeTooltip).top.replace('px', '')) - 20
145 147
146 const previewHalfSize = Math.round(scaledWidth / 2) 148 const previewHalfSize = Math.round(scaledWidth / 2)
147 let left = seekBarRect.width * seekBarX - previewHalfSize 149 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 @@
1import { timeToInt } from '@peertube/peertube-core-utils'
2import videojs, { VideoJsPlayer } from 'video.js'
3
4const TimeToolTip = videojs.getComponent('TimeTooltip') as any // FIXME: typings don't have write method
5
6class TimeTooltip extends TimeToolTip {
7
8 write (timecode: string) {
9 const player: VideoJsPlayer = this.player()
10
11 if (player.usingPlugin('chapters')) {
12 const chapterTitle = player.chapters().getChapter(timeToInt(timecode))
13 if (chapterTitle) return super.write(chapterTitle + '\r\n' + timecode)
14 }
15
16 return super.write(timecode)
17 }
18}
19
20videojs.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 @@
1import { LiveVideoLatencyModeType, VideoFile } from '@peertube/peertube-models' 1import { LiveVideoLatencyModeType, VideoChapter, VideoFile } from '@peertube/peertube-models'
2import { PluginsManager } from '@root-helpers/plugins-manager' 2import { PluginsManager } from '@root-helpers/plugins-manager'
3import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' 3import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
4import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' 4import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings'
@@ -68,6 +68,7 @@ export type PeerTubePlayerLoadOptions = {
68 } 68 }
69 69
70 videoCaptions: VideoJSCaption[] 70 videoCaptions: VideoJSCaption[]
71 videoChapters: VideoChapter[]
71 storyboard: VideoJSStoryboard 72 storyboard: VideoJSStoryboard
72 73
73 videoUUID: string 74 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 @@
1import { HlsConfig, Level } from 'hls.js' 1import { HlsConfig, Level } from 'hls.js'
2import videojs from 'video.js' 2import videojs from 'video.js'
3import { Engine } from '@peertube/p2p-media-loader-hlsjs' 3import { Engine } from '@peertube/p2p-media-loader-hlsjs'
4import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' 4import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models'
5import { BezelsPlugin } from '../shared/bezels/bezels-plugin' 5import { BezelsPlugin } from '../shared/bezels/bezels-plugin'
6import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' 6import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin'
7import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' 7import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
@@ -19,6 +19,7 @@ import { UpNextPlugin } from '../shared/upnext/upnext-plugin'
19import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' 19import { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
20import { PlayerMode } from './peertube-player-options' 20import { PlayerMode } from './peertube-player-options'
21import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' 21import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
22import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin'
22 23
23declare module 'video.js' { 24declare module 'video.js' {
24 25
@@ -62,6 +63,8 @@ declare module 'video.js' {
62 63
63 peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin 64 peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin
64 65
66 chapters (options?: ChaptersOptions): ChaptersPlugin
67
65 upnext (options?: UpNextPluginOptions): UpNextPlugin 68 upnext (options?: UpNextPluginOptions): UpNextPlugin
66 69
67 playlist (options?: PlaylistPluginOptions): PlaylistPlugin 70 playlist (options?: PlaylistPluginOptions): PlaylistPlugin
@@ -142,6 +145,10 @@ type StoryboardOptions = {
142 interval: number 145 interval: number
143} 146}
144 147
148type ChaptersOptions = {
149 chapters: VideoChapter[]
150}
151
145type PlaylistPluginOptions = { 152type PlaylistPluginOptions = {
146 elements: VideoPlaylistElement[] 153 elements: VideoPlaylistElement[]
147 154
@@ -161,6 +168,10 @@ type UpNextPluginOptions = {
161 isSuspended: () => boolean 168 isSuspended: () => boolean
162} 169}
163 170
171type ProgressBarMarkerComponentOptions = {
172 timecode: number
173}
174
164type NextPreviousVideoButtonOptions = { 175type NextPreviousVideoButtonOptions = {
165 type: 'next' | 'previous' 176 type: 'next' | 'previous'
166 handler?: () => void 177 handler?: () => void
@@ -273,6 +284,7 @@ export {
273 NextPreviousVideoButtonOptions, 284 NextPreviousVideoButtonOptions,
274 ResolutionUpdateData, 285 ResolutionUpdateData,
275 AutoResolutionUpdateData, 286 AutoResolutionUpdateData,
287 ProgressBarMarkerComponentOptions,
276 PlaylistPluginOptions, 288 PlaylistPluginOptions,
277 MetricsPluginOptions, 289 MetricsPluginOptions,
278 VideoJSCaption, 290 VideoJSCaption,
@@ -284,5 +296,6 @@ export {
284 UpNextPluginOptions, 296 UpNextPluginOptions,
285 LoadedQualityData, 297 LoadedQualityData,
286 StoryboardOptions, 298 StoryboardOptions,
299 ChaptersOptions,
287 PeerTubeLinkButtonOptions 300 PeerTubeLinkButtonOptions
288} 301}
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 @@
3@use '_mixins' as *; 3@use '_mixins' as *;
4@use './_player-variables' as *; 4@use './_player-variables' as *;
5 5
6.vjs-peertube-skin.has-chapter {
7 .vjs-time-tooltip {
8 white-space: pre;
9 line-height: 1.5;
10 padding-top: 4px;
11 padding-bottom: 4px;
12 top: -4.9em;
13 }
14}
15
6.video-js.vjs-peertube-skin .vjs-control-bar { 16.video-js.vjs-peertube-skin .vjs-control-bar {
7 z-index: 100; 17 z-index: 100;
8 18
@@ -495,3 +505,12 @@
495 } 505 }
496 } 506 }
497} 507}
508
509.vjs-marker {
510 position: absolute;
511 width: 3px;
512 opacity: .5;
513 background-color: #000;
514 height: 100%;
515 top: 0;
516}
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 {
195 const { 195 const {
196 videoResponse, 196 videoResponse,
197 captionsPromise, 197 captionsPromise,
198 chaptersPromise,
198 storyboardsPromise 199 storyboardsPromise
199 } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword }) 200 } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword })
200 201
201 return this.buildVideoPlayer({ videoResponse, captionsPromise, storyboardsPromise, forceAutoplay }) 202 return this.buildVideoPlayer({ videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay })
202 } catch (err) { 203 } catch (err) {
203 204
204 if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options }) 205 if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
@@ -210,9 +211,10 @@ export class PeerTubeEmbed {
210 videoResponse: Response 211 videoResponse: Response
211 storyboardsPromise: Promise<Response> 212 storyboardsPromise: Promise<Response>
212 captionsPromise: Promise<Response> 213 captionsPromise: Promise<Response>
214 chaptersPromise: Promise<Response>
213 forceAutoplay: boolean 215 forceAutoplay: boolean
214 }) { 216 }) {
215 const { videoResponse, captionsPromise, storyboardsPromise, forceAutoplay } = options 217 const { videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay } = options
216 218
217 const videoInfoPromise = videoResponse.json() 219 const videoInfoPromise = videoResponse.json()
218 .then(async (videoInfo: VideoDetails) => { 220 .then(async (videoInfo: VideoDetails) => {
@@ -233,11 +235,13 @@ export class PeerTubeEmbed {
233 { video, live, videoFileToken }, 235 { video, live, videoFileToken },
234 translations, 236 translations,
235 captionsResponse, 237 captionsResponse,
238 chaptersResponse,
236 storyboardsResponse 239 storyboardsResponse
237 ] = await Promise.all([ 240 ] = await Promise.all([
238 videoInfoPromise, 241 videoInfoPromise,
239 this.translationsPromise, 242 this.translationsPromise,
240 captionsPromise, 243 captionsPromise,
244 chaptersPromise,
241 storyboardsPromise, 245 storyboardsPromise,
242 this.buildPlayerIfNeeded() 246 this.buildPlayerIfNeeded()
243 ]) 247 ])
@@ -260,6 +264,7 @@ export class PeerTubeEmbed {
260 const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({ 264 const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({
261 video, 265 video,
262 captionsResponse, 266 captionsResponse,
267 chaptersResponse,
263 translations, 268 translations,
264 269
265 storyboardsResponse, 270 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 {
5 Storyboard, 5 Storyboard,
6 Video, 6 Video,
7 VideoCaption, 7 VideoCaption,
8 VideoChapter,
8 VideoDetails, 9 VideoDetails,
9 VideoPlaylistElement, 10 VideoPlaylistElement,
10 VideoState, 11 VideoState,
@@ -199,6 +200,8 @@ export class PlayerOptionsBuilder {
199 200
200 storyboardsResponse: Response 201 storyboardsResponse: Response
201 202
203 chaptersResponse: Response
204
202 live?: LiveVideo 205 live?: LiveVideo
203 206
204 alreadyPlayed: boolean 207 alreadyPlayed: boolean
@@ -229,12 +232,14 @@ export class PlayerOptionsBuilder {
229 forceAutoplay, 232 forceAutoplay,
230 playlist, 233 playlist,
231 live, 234 live,
232 storyboardsResponse 235 storyboardsResponse,
236 chaptersResponse
233 } = options 237 } = options
234 238
235 const [ videoCaptions, storyboard ] = await Promise.all([ 239 const [ videoCaptions, storyboard, chapters ] = await Promise.all([
236 this.buildCaptions(captionsResponse, translations), 240 this.buildCaptions(captionsResponse, translations),
237 this.buildStoryboard(storyboardsResponse) 241 this.buildStoryboard(storyboardsResponse),
242 this.buildChapters(chaptersResponse)
238 ]) 243 ])
239 244
240 return { 245 return {
@@ -248,6 +253,7 @@ export class PlayerOptionsBuilder {
248 subtitle: this.subtitle, 253 subtitle: this.subtitle,
249 254
250 storyboard, 255 storyboard,
256 videoChapters: chapters,
251 257
252 startTime: playlist 258 startTime: playlist
253 ? playlist.playlistTracker.getCurrentElement().startTimestamp 259 ? playlist.playlistTracker.getCurrentElement().startTimestamp
@@ -312,6 +318,12 @@ export class PlayerOptionsBuilder {
312 } 318 }
313 } 319 }
314 320
321 private async buildChapters (chaptersResponse: Response) {
322 const { chapters } = await chaptersResponse.json() as { chapters: VideoChapter[] }
323
324 return chapters
325 }
326
315 private buildPlaylistOptions (options?: { 327 private buildPlaylistOptions (options?: {
316 playlistTracker: PlaylistTracker 328 playlistTracker: PlaylistTracker
317 playNext: () => any 329 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 {
36 } 36 }
37 37
38 const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword }) 38 const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword })
39 const chaptersPromise = this.loadVideoChapters({ videoId, videoPassword })
39 const storyboardsPromise = this.loadStoryboards(videoId) 40 const storyboardsPromise = this.loadStoryboards(videoId)
40 41
41 return { captionsPromise, storyboardsPromise, videoResponse } 42 return { captionsPromise, chaptersPromise, storyboardsPromise, videoResponse }
42 } 43 }
43 44
44 loadLive (video: VideoDetails) { 45 loadLive (video: VideoDetails) {
@@ -64,6 +65,10 @@ export class VideoFetcher {
64 return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword) 65 return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword)
65 } 66 }
66 67
68 private loadVideoChapters ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
69 return this.http.fetch(this.getVideoUrl(videoId) + '/chapters', { optionalAuth: true }, videoPassword)
70 }
71
67 private getVideoUrl (id: string) { 72 private getVideoUrl (id: string) {
68 return window.location.origin + '/api/v1/videos/' + id 73 return window.location.origin + '/api/v1/videos/' + id
69 } 74 }
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 @@
1function findCommonElement <T> (array1: T[], array2: T[]) { 1export function findCommonElement <T> (array1: T[], array2: T[]) {
2 for (const a of array1) { 2 for (const a of array1) {
3 for (const b of array2) { 3 for (const b of array2) {
4 if (a === b) return a 4 if (a === b) return a
@@ -9,19 +9,19 @@ function findCommonElement <T> (array1: T[], array2: T[]) {
9} 9}
10 10
11// Avoid conflict with other toArray() functions 11// Avoid conflict with other toArray() functions
12function arrayify <T> (element: T | T[]) { 12export function arrayify <T> (element: T | T[]) {
13 if (Array.isArray(element)) return element 13 if (Array.isArray(element)) return element
14 14
15 return [ element ] 15 return [ element ]
16} 16}
17 17
18// Avoid conflict with other uniq() functions 18// Avoid conflict with other uniq() functions
19function uniqify <T> (elements: T[]) { 19export function uniqify <T> (elements: T[]) {
20 return Array.from(new Set(elements)) 20 return Array.from(new Set(elements))
21} 21}
22 22
23// Thanks: https://stackoverflow.com/a/12646864 23// Thanks: https://stackoverflow.com/a/12646864
24function shuffle <T> (elements: T[]) { 24export function shuffle <T> (elements: T[]) {
25 const shuffled = [ ...elements ] 25 const shuffled = [ ...elements ]
26 26
27 for (let i = shuffled.length - 1; i > 0; i--) { 27 for (let i = shuffled.length - 1; i > 0; i--) {
@@ -33,9 +33,13 @@ function shuffle <T> (elements: T[]) {
33 return shuffled 33 return shuffled
34} 34}
35 35
36export { 36export function sortBy (obj: any[], key1: string, key2?: string) {
37 uniqify, 37 return obj.sort((a, b) => {
38 findCommonElement, 38 const elem1 = key2 ? a[key1][key2] : a[key1]
39 shuffle, 39 const elem2 = key2 ? b[key1][key2] : b[key1]
40 arrayify 40
41 if (elem1 < elem2) return -1
42 if (elem1 === elem2) return 0
43 return 1
44 })
41} 45}
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) {
45 45
46// --------------------------------------------------------------------------- 46// ---------------------------------------------------------------------------
47 47
48export const timecodeRegexString = `((\\d+)[h:])?((\\d+)[m:])?((\\d+)s?)?`
49
48function timeToInt (time: number | string) { 50function timeToInt (time: number | string) {
49 if (!time) return 0 51 if (!time) return 0
50 if (typeof time === 'number') return time 52 if (typeof time === 'number') return time
51 53
52 const reg = /^((\d+)[h:])?((\d+)[m:])?((\d+)s?)?$/ 54 const reg = new RegExp(`^${timecodeRegexString}$`)
53 const matches = time.match(reg) 55 const matches = time.match(reg)
54 56
55 if (!matches) return 0 57 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'
5export * from './renderer/index.js' 5export * from './renderer/index.js'
6export * from './users/index.js' 6export * from './users/index.js'
7export * from './videos/index.js' 7export * from './videos/index.js'
8export * 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 @@
1import { timeToInt, timecodeRegexString } from '../common/date.js'
2
3const timecodeRegex = new RegExp(`^(${timecodeRegexString})\\s`)
4
5export function parseChapters (text: string) {
6 if (!text) return []
7
8 const lines = text.split(/\r?\n|\r|\n/g)
9 let foundChapters = false
10
11 const chapters: { timecode: number, title: string }[] = []
12
13 for (const line of lines) {
14 const matched = line.match(timecodeRegex)
15 if (!matched) {
16 // Stop chapters parsing
17 if (foundChapters) break
18
19 continue
20 }
21
22 foundChapters = true
23
24 const timecodeText = matched[1]
25 const timecode = timeToInt(timecodeText)
26 const title = line.replace(matched[0], '')
27
28 chapters.push({ timecode, title })
29 }
30
31 return chapters
32}
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'
10 10
11function ffprobePromise (path: string) { 11function ffprobePromise (path: string) {
12 return new Promise<FfprobeData>((res, rej) => { 12 return new Promise<FfprobeData>((res, rej) => {
13 ffmpeg.ffprobe(path, (err, data) => { 13 ffmpeg.ffprobe(path, [ '-show_chapters' ], (err, data) => {
14 if (err) return rej(err) 14 if (err) return rej(err)
15 15
16 return res(data) 16 return res(data)
@@ -169,9 +169,26 @@ async function getVideoStream (path: string, existingProbe?: FfprobeData) {
169} 169}
170 170
171// --------------------------------------------------------------------------- 171// ---------------------------------------------------------------------------
172// Chapters
173// ---------------------------------------------------------------------------
174
175async function getChaptersFromContainer (path: string, existingProbe?: FfprobeData) {
176 const metadata = existingProbe || await ffprobePromise(path)
177
178 if (!Array.isArray(metadata?.chapters)) return []
179
180 return metadata.chapters
181 .map(c => ({
182 timecode: c.start_time,
183 title: c['TAG:title']
184 }))
185}
186
187// ---------------------------------------------------------------------------
172 188
173export { 189export {
174 getVideoStreamDimensionsInfo, 190 getVideoStreamDimensionsInfo,
191 getChaptersFromContainer,
175 getMaxAudioBitrate, 192 getMaxAudioBitrate,
176 getVideoStream, 193 getVideoStream,
177 getVideoStreamDuration, 194 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 =
13 'Flag' | 13 'Flag' |
14 'Actor' | 14 'Actor' |
15 'Collection' | 15 'Collection' |
16 'WatchAction' 16 'WatchAction' |
17 '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'
4export * from './common-objects.js' 4export * from './common-objects.js'
5export * from './playlist-element-object.js' 5export * from './playlist-element-object.js'
6export * from './playlist-object.js' 6export * from './playlist-object.js'
7export * from './video-chapters-object.js'
7export * from './video-comment-object.js' 8export * from './video-comment-object.js'
8export * from './video-object.js' 9export * from './video-object.js'
9export * from './watch-action-object.js' 10export * 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 @@
1export interface VideoChaptersObject {
2 id: string
3 hasPart: VideoChapterObject[]
4}
5
6// Same as https://schema.org/hasPart
7export interface VideoChapterObject {
8 name: string
9 startOffset: number
10 endOffset: number
11}
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 {
50 dislikes: string 50 dislikes: string
51 shares: string 51 shares: string
52 comments: string 52 comments: string
53 hasParts: string
53 54
54 attributedTo: ActivityPubAttributedTo[] 55 attributedTo: ActivityPubAttributedTo[]
55 56
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 @@
1export interface VideoChapterUpdate {
2 chapters: {
3 timecode: number
4 title: string
5 }[]
6}
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 @@
1export interface VideoChapter {
2 timecode: number
3 title: string
4}
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 @@
1export * from './chapter-update.model.js'
2export * 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'
12export * from './stats/index.js' 12export * from './stats/index.js'
13export * from './transcoding/index.js' 13export * from './transcoding/index.js'
14export * from './channel-sync/index.js' 14export * from './channel-sync/index.js'
15export * from './chapter/index.js'
15 16
16export * from './nsfw-policy.type.js' 17export * from './nsfw-policy.type.js'
17 18
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 {
30 ChangeOwnershipCommand, 30 ChangeOwnershipCommand,
31 ChannelsCommand, 31 ChannelsCommand,
32 ChannelSyncsCommand, 32 ChannelSyncsCommand,
33 ChaptersCommand,
33 CommentsCommand, 34 CommentsCommand,
34 HistoryCommand, 35 HistoryCommand,
35 ImportsCommand, 36 ImportsCommand,
@@ -152,6 +153,7 @@ export class PeerTubeServer {
152 videoPasswords?: VideoPasswordsCommand 153 videoPasswords?: VideoPasswordsCommand
153 154
154 storyboard?: StoryboardCommand 155 storyboard?: StoryboardCommand
156 chapters?: ChaptersCommand
155 157
156 runners?: RunnersCommand 158 runners?: RunnersCommand
157 runnerRegistrationTokens?: RunnerRegistrationTokensCommand 159 runnerRegistrationTokens?: RunnerRegistrationTokensCommand
@@ -442,6 +444,7 @@ export class PeerTubeServer {
442 this.registrations = new RegistrationsCommand(this) 444 this.registrations = new RegistrationsCommand(this)
443 445
444 this.storyboard = new StoryboardCommand(this) 446 this.storyboard = new StoryboardCommand(this)
447 this.chapters = new ChaptersCommand(this)
445 448
446 this.runners = new RunnersCommand(this) 449 this.runners = new RunnersCommand(this)
447 this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this) 450 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 @@
1import {
2 HttpStatusCode, VideoChapterUpdate, VideoChapters
3} from '@peertube/peertube-models'
4import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
5
6export class ChaptersCommand extends AbstractCommand {
7
8 list (options: OverrideCommandOptions & {
9 videoId: string | number
10 }) {
11 const path = '/api/v1/videos/' + options.videoId + '/chapters'
12
13 return this.getRequestBody<VideoChapters>({
14 ...options,
15
16 path,
17 implicitToken: true,
18 defaultExpectedStatus: HttpStatusCode.OK_200
19 })
20 }
21
22 update (options: OverrideCommandOptions & VideoChapterUpdate & {
23 videoId: number | string
24 }) {
25 const path = '/api/v1/videos/' + options.videoId + '/chapters'
26
27 return this.putBodyRequest({
28 ...options,
29
30 path,
31 fields: {
32 chapters: options.chapters
33 },
34 implicitToken: true,
35 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
36 })
37 }
38}
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'
3export * from './change-ownership-command.js' 3export * from './change-ownership-command.js'
4export * from './channels.js' 4export * from './channels.js'
5export * from './channels-command.js' 5export * from './channels-command.js'
6export * from './chapters-command.js'
6export * from './channel-syncs-command.js' 7export * from './channel-syncs-command.js'
7export * from './comments-command.js' 8export * from './comments-command.js'
8export * from './history-command.js' 9export * from './history-command.js'
diff --git a/packages/tests/fixtures/video_chapters.mp4 b/packages/tests/fixtures/video_chapters.mp4
new file mode 100644
index 000000000..46cbaf624
--- /dev/null
+++ b/packages/tests/fixtures/video_chapters.mp4
Binary files differ
diff --git a/packages/tests/src/api/check-params/index.ts b/packages/tests/src/api/check-params/index.ts
index ed5fe6b06..d7867e8a5 100644
--- a/packages/tests/src/api/check-params/index.ts
+++ b/packages/tests/src/api/check-params/index.ts
@@ -30,6 +30,7 @@ import './video-blacklist.js'
30import './video-captions.js' 30import './video-captions.js'
31import './video-channel-syncs.js' 31import './video-channel-syncs.js'
32import './video-channels.js' 32import './video-channels.js'
33import './video-chapters.js'
33import './video-comments.js' 34import './video-comments.js'
34import './video-files.js' 35import './video-files.js'
35import './video-imports.js' 36import './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 () {
31 31
32 video = await server.videos.upload() 32 video = await server.videos.upload()
33 privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } }) 33 privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } })
34 34 userAccessToken = await server.users.generateUserAndToken('user1')
35 {
36 const user = {
37 username: 'user1',
38 password: 'my super password'
39 }
40 await server.users.create({ username: user.username, password: user.password })
41 userAccessToken = await server.login.getAccessToken(user)
42 }
43 }) 35 })
44 36
45 describe('When adding video caption', function () { 37 describe('When adding video caption', function () {
@@ -120,6 +112,19 @@ describe('Test video captions API validator', function () {
120 }) 112 })
121 }) 113 })
122 114
115 it('Should fail with another user token', async function () {
116 const captionPath = path + video.uuid + '/captions/fr'
117 await makeUploadRequest({
118 method: 'PUT',
119 url: server.url,
120 path: captionPath,
121 token: userAccessToken,
122 fields,
123 attaches,
124 expectedStatus: HttpStatusCode.FORBIDDEN_403
125 })
126 })
127
123 // We accept any file now 128 // We accept any file now
124 // it('Should fail with an invalid captionfile extension', async function () { 129 // it('Should fail with an invalid captionfile extension', async function () {
125 // const attaches = { 130 // 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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { HttpStatusCode, Video, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
4import {
5 PeerTubeServer,
6 cleanupTests,
7 createSingleServer,
8 setAccessTokensToServers,
9 setDefaultVideoChannel
10} from '@peertube/peertube-server-commands'
11
12describe('Test videos chapters API validator', function () {
13 let server: PeerTubeServer
14 let video: VideoCreateResult
15 let live: Video
16 let privateVideo: VideoCreateResult
17 let userAccessToken: string
18
19 // ---------------------------------------------------------------
20
21 before(async function () {
22 this.timeout(30000)
23
24 server = await createSingleServer(1)
25
26 await setAccessTokensToServers([ server ])
27 await setDefaultVideoChannel([ server ])
28
29 video = await server.videos.upload()
30 privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } })
31 userAccessToken = await server.users.generateUserAndToken('user1')
32
33 await server.config.enableLive({ allowReplay: false })
34
35 const res = await server.live.quickCreate({ saveReplay: false, permanentLive: false })
36 live = res.video
37 })
38
39 describe('When updating chapters', function () {
40
41 it('Should fail without a valid uuid', async function () {
42 await server.chapters.update({ videoId: '4da6fd', chapters: [], expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
43 })
44
45 it('Should fail with an unknown id', async function () {
46 await server.chapters.update({
47 videoId: 'ce0801ef-7124-48df-9b22-b473ace78797',
48 chapters: [],
49 expectedStatus: HttpStatusCode.NOT_FOUND_404
50 })
51 })
52
53 it('Should fail without access token', async function () {
54 await server.chapters.update({
55 videoId: video.id,
56 chapters: [],
57 token: null,
58 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
59 })
60 })
61
62 it('Should fail with a bad access token', async function () {
63 await server.chapters.update({
64 videoId: video.id,
65 chapters: [],
66 token: 'toto',
67 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
68 })
69 })
70
71 it('Should fail with a another user access token', async function () {
72 await server.chapters.update({
73 videoId: video.id,
74 chapters: [],
75 token: userAccessToken,
76 expectedStatus: HttpStatusCode.FORBIDDEN_403
77 })
78 })
79
80 it('Should fail with a wrong chapters param', async function () {
81 await server.chapters.update({
82 videoId: video.id,
83 chapters: 'hello' as any,
84 expectedStatus: HttpStatusCode.BAD_REQUEST_400
85 })
86 })
87
88 it('Should fail with a bad chapter title', async function () {
89 await server.chapters.update({
90 videoId: video.id,
91 chapters: [ { title: 'hello', timecode: 21 }, { title: '', timecode: 21 } ],
92 expectedStatus: HttpStatusCode.BAD_REQUEST_400
93 })
94
95 await server.chapters.update({
96 videoId: video.id,
97 chapters: [ { title: 'hello', timecode: 21 }, { title: 'a'.repeat(150), timecode: 21 } ],
98 expectedStatus: HttpStatusCode.BAD_REQUEST_400
99 })
100 })
101
102 it('Should fail with a bad timecode', async function () {
103 await server.chapters.update({
104 videoId: video.id,
105 chapters: [ { title: 'hello', timecode: 21 }, { title: 'title', timecode: -5 } ],
106 expectedStatus: HttpStatusCode.BAD_REQUEST_400
107 })
108
109 await server.chapters.update({
110 videoId: video.id,
111 chapters: [ { title: 'hello', timecode: 21 }, { title: 'title', timecode: 'hi' as any } ],
112 expectedStatus: HttpStatusCode.BAD_REQUEST_400
113 })
114 })
115
116 it('Should fail with non unique timecodes', async function () {
117 await server.chapters.update({
118 videoId: video.id,
119 chapters: [ { title: 'hello', timecode: 21 }, { title: 'title', timecode: 22 }, { title: 'hello', timecode: 21 } ],
120 expectedStatus: HttpStatusCode.BAD_REQUEST_400
121 })
122 })
123
124 it('Should fail to create chapters on a live', async function () {
125 await server.chapters.update({
126 videoId: live.id,
127 chapters: [],
128 expectedStatus: HttpStatusCode.BAD_REQUEST_400
129 })
130 })
131
132 it('Should succeed with the correct params', async function () {
133 await server.chapters.update({
134 videoId: video.id,
135 chapters: []
136 })
137
138 await server.chapters.update({
139 videoId: video.id,
140 chapters: [ { title: 'hello', timecode: 21 }, { title: 'hello 2', timecode: 35 } ]
141 })
142 })
143 })
144
145 describe('When listing chapters', function () {
146
147 it('Should fail without a valid uuid', async function () {
148 await server.chapters.list({ videoId: '4da6fd', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
149 })
150
151 it('Should fail with an unknown id', async function () {
152 await server.chapters.list({ videoId: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
153 })
154
155 it('Should not list private chapters to anyone', async function () {
156 await server.chapters.list({ videoId: privateVideo.uuid, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
157 })
158
159 it('Should not list private chapters to another user', async function () {
160 await server.chapters.list({ videoId: privateVideo.uuid, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
161 })
162
163 it('Should list chapters', async function () {
164 await server.chapters.list({ videoId: privateVideo.uuid })
165 await server.chapters.list({ videoId: video.uuid })
166 })
167 })
168
169 after(async function () {
170 await cleanupTests([ server ])
171 })
172})
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'
4import './video-captions.js' 4import './video-captions.js'
5import './video-change-ownership.js' 5import './video-change-ownership.js'
6import './video-channels.js' 6import './video-channels.js'
7import './video-chapters.js'
7import './channel-import-videos.js' 8import './channel-import-videos.js'
8import './video-channel-syncs.js' 9import './video-channel-syncs.js'
9import './video-comments.js' 10import './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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { VideoChapter, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
4import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils'
5import {
6 cleanupTests,
7 createMultipleServers,
8 doubleFollow, PeerTubeServer, setAccessTokensToServers,
9 setDefaultVideoChannel,
10 waitJobs
11} from '@peertube/peertube-server-commands'
12import { FIXTURE_URLS } from '@tests/shared/tests.js'
13import { expect } from 'chai'
14
15describe('Test video chapters', function () {
16 let servers: PeerTubeServer[]
17
18 before(async function () {
19 this.timeout(120000)
20
21 servers = await createMultipleServers(2)
22 await setAccessTokensToServers(servers)
23 await setDefaultVideoChannel(servers)
24
25 await doubleFollow(servers[0], servers[1])
26 })
27
28 describe('Common tests', function () {
29 let video: VideoCreateResult
30
31 before(async function () {
32 this.timeout(120000)
33
34 video = await servers[0].videos.quickUpload({ name: 'video' })
35 await waitJobs(servers)
36 })
37
38 it('Should not have chapters', async function () {
39 for (const server of servers) {
40 const { chapters } = await server.chapters.list({ videoId: video.uuid })
41
42 expect(chapters).to.deep.equal([])
43 }
44 })
45
46 it('Should set chaptets', async function () {
47 await servers[0].chapters.update({
48 videoId: video.uuid,
49 chapters: [
50 { title: 'chapter 1', timecode: 45 },
51 { title: 'chapter 2', timecode: 58 }
52 ]
53 })
54 await waitJobs(servers)
55
56 for (const server of servers) {
57 const { chapters } = await server.chapters.list({ videoId: video.uuid })
58
59 expect(chapters).to.deep.equal([
60 { title: 'chapter 1', timecode: 45 },
61 { title: 'chapter 2', timecode: 58 }
62 ])
63 }
64 })
65
66 it('Should add new chapters', async function () {
67 await servers[0].chapters.update({
68 videoId: video.uuid,
69 chapters: [
70 { title: 'chapter 1', timecode: 45 },
71 { title: 'chapter 2', timecode: 46 },
72 { title: 'chapter 3', timecode: 58 }
73 ]
74 })
75 await waitJobs(servers)
76
77 for (const server of servers) {
78 const { chapters } = await server.chapters.list({ videoId: video.uuid })
79
80 expect(chapters).to.deep.equal([
81 { title: 'chapter 1', timecode: 45 },
82 { title: 'chapter 2', timecode: 46 },
83 { title: 'chapter 3', timecode: 58 }
84 ])
85 }
86 })
87
88 it('Should delete all chapters', async function () {
89 await servers[0].chapters.update({ videoId: video.uuid, chapters: [] })
90 await waitJobs(servers)
91
92 for (const server of servers) {
93 const { chapters } = await server.chapters.list({ videoId: video.uuid })
94
95 expect(chapters).to.deep.equal([])
96 }
97 })
98 })
99
100 describe('With chapters in description', function () {
101 const description = 'this is a super description\n' +
102 '00:00 chapter 1\n' +
103 '00:03 chapter 2\n' +
104 '00:04 chapter 3\n'
105
106 function checkChapters (chapters: VideoChapter[]) {
107 expect(chapters).to.deep.equal([
108 {
109 timecode: 0,
110 title: 'chapter 1'
111 },
112 {
113 timecode: 3,
114 title: 'chapter 2'
115 },
116 {
117 timecode: 4,
118 title: 'chapter 3'
119 }
120 ])
121 }
122
123 it('Should upload a video with chapters in description', async function () {
124 const video = await servers[0].videos.upload({ attributes: { name: 'description', description } })
125 await waitJobs(servers)
126
127 for (const server of servers) {
128 const { chapters } = await server.chapters.list({ videoId: video.uuid })
129
130 checkChapters(chapters)
131 }
132 })
133
134 it('Should update a video description and automatically add chapters', async function () {
135 const video = await servers[0].videos.quickUpload({ name: 'update description' })
136 await waitJobs(servers)
137
138 for (const server of servers) {
139 const { chapters } = await server.chapters.list({ videoId: video.uuid })
140
141 expect(chapters).to.deep.equal([])
142 }
143
144 await servers[0].videos.update({ id: video.uuid, attributes: { description } })
145 await waitJobs(servers)
146
147 for (const server of servers) {
148 const { chapters } = await server.chapters.list({ videoId: video.uuid })
149
150 checkChapters(chapters)
151 }
152 })
153
154 it('Should update a video description but not automatically add chapters since the video already has chapters', async function () {
155 const video = await servers[0].videos.quickUpload({ name: 'update description' })
156
157 await servers[0].chapters.update({ videoId: video.uuid, chapters: [ { timecode: 5, title: 'chapter 1' } ] })
158 await servers[0].videos.update({ id: video.uuid, attributes: { description } })
159
160 await waitJobs(servers)
161
162 for (const server of servers) {
163 const { chapters } = await server.chapters.list({ videoId: video.uuid })
164
165 expect(chapters).to.deep.equal([ { timecode: 5, title: 'chapter 1' } ])
166 }
167 })
168
169 it('Should update multiple times chapters from description', async function () {
170 const video = await servers[0].videos.quickUpload({ name: 'update description' })
171
172 await servers[0].videos.update({ id: video.uuid, attributes: { description } })
173 await waitJobs(servers)
174
175 for (const server of servers) {
176 const { chapters } = await server.chapters.list({ videoId: video.uuid })
177
178 checkChapters(chapters)
179 }
180
181 await servers[0].videos.update({ id: video.uuid, attributes: { description: '00:01 chapter 1' } })
182 await waitJobs(servers)
183
184 for (const server of servers) {
185 const { chapters } = await server.chapters.list({ videoId: video.uuid })
186
187 expect(chapters).to.deep.equal([ { timecode: 1, title: 'chapter 1' } ])
188 }
189
190 await servers[0].videos.update({ id: video.uuid, attributes: { description: 'null description' } })
191 await waitJobs(servers)
192
193 for (const server of servers) {
194 const { chapters } = await server.chapters.list({ videoId: video.uuid })
195
196 expect(chapters).to.deep.equal([])
197 }
198 })
199 })
200
201 describe('With upload', function () {
202
203 it('Should upload a mp4 containing chapters and automatically add them', async function () {
204 const video = await servers[0].videos.quickUpload({ fixture: 'video_chapters.mp4', name: 'chapters' })
205 await waitJobs(servers)
206
207 for (const server of servers) {
208 const { chapters } = await server.chapters.list({ videoId: video.uuid })
209
210 expect(chapters).to.deep.equal([
211 {
212 timecode: 0,
213 title: 'Chapter 1'
214 },
215 {
216 timecode: 2,
217 title: 'Chapter 2'
218 },
219 {
220 timecode: 4,
221 title: 'Chapter 3'
222 }
223 ])
224 }
225 })
226 })
227
228 describe('With URL import', function () {
229 if (areHttpImportTestsDisabled()) return
230
231 it('Should detect chapters from youtube URL import', async function () {
232 this.timeout(120000)
233
234 const attributes = {
235 channelId: servers[0].store.channel.id,
236 privacy: VideoPrivacy.PUBLIC,
237 targetUrl: FIXTURE_URLS.youtubeChapters,
238 description: 'this is a super description\n'
239 }
240 const { video } = await servers[0].imports.importVideo({ attributes })
241
242 await waitJobs(servers)
243
244 for (const server of servers) {
245 const { chapters } = await server.chapters.list({ videoId: video.uuid })
246
247 expect(chapters).to.deep.equal([
248 {
249 timecode: 0,
250 title: 'chapter 1'
251 },
252 {
253 timecode: 15,
254 title: 'chapter 2'
255 },
256 {
257 timecode: 35,
258 title: 'chapter 3'
259 },
260 {
261 timecode: 40,
262 title: 'chapter 4'
263 }
264 ])
265 }
266 })
267
268 it('Should have overriden description priority from youtube URL import', async function () {
269 this.timeout(120000)
270
271 const attributes = {
272 channelId: servers[0].store.channel.id,
273 privacy: VideoPrivacy.PUBLIC,
274 targetUrl: FIXTURE_URLS.youtubeChapters,
275 description: 'this is a super description\n' +
276 '00:00 chapter 1\n' +
277 '00:03 chapter 2\n' +
278 '00:04 chapter 3\n'
279 }
280 const { video } = await servers[0].imports.importVideo({ attributes })
281
282 await waitJobs(servers)
283
284 for (const server of servers) {
285 const { chapters } = await server.chapters.list({ videoId: video.uuid })
286
287 expect(chapters).to.deep.equal([
288 {
289 timecode: 0,
290 title: 'chapter 1'
291 },
292 {
293 timecode: 3,
294 title: 'chapter 2'
295 },
296 {
297 timecode: 4,
298 title: 'chapter 3'
299 }
300 ])
301 }
302 })
303
304 it('Should detect chapters from raw URL import', async function () {
305 this.timeout(120000)
306
307 const attributes = {
308 channelId: servers[0].store.channel.id,
309 privacy: VideoPrivacy.PUBLIC,
310 targetUrl: FIXTURE_URLS.chatersVideo
311 }
312 const { video } = await servers[0].imports.importVideo({ attributes })
313
314 await waitJobs(servers)
315
316 for (const server of servers) {
317 const { chapters } = await server.chapters.list({ videoId: video.uuid })
318
319 expect(chapters).to.deep.equal([
320 {
321 timecode: 0,
322 title: 'Chapter 1'
323 },
324 {
325 timecode: 2,
326 title: 'Chapter 2'
327 },
328 {
329 timecode: 4,
330 title: 'Chapter 3'
331 }
332 ])
333 }
334 })
335 })
336
337 // TODO: test torrent import too
338
339 after(async function () {
340 await cleanupTests(servers)
341 })
342})
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 @@
3import { expect } from 'chai' 3import { expect } from 'chai'
4import snakeCase from 'lodash-es/snakeCase.js' 4import snakeCase from 'lodash-es/snakeCase.js'
5import validator from 'validator' 5import validator from 'validator'
6import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate } from '@peertube/peertube-core-utils' 6import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, parseChapters } from '@peertube/peertube-core-utils'
7import { VideoResolution } from '@peertube/peertube-models' 7import { VideoResolution } from '@peertube/peertube-models'
8import { objectConverter, parseBytes, parseDurationToMs, parseSemVersion } from '@peertube/peertube-server/server/helpers/core-utils.js' 8import { objectConverter, parseBytes, parseDurationToMs, parseSemVersion } from '@peertube/peertube-server/server/helpers/core-utils.js'
9 9
@@ -199,3 +199,28 @@ describe('Parse semantic version string', function () {
199 expect(actual.patch).to.equal(0) 199 expect(actual.patch).to.equal(0)
200 }) 200 })
201}) 201})
202
203describe('Extract chapters', function () {
204
205 it('Should not extract chapters', function () {
206 expect(parseChapters('my super description\nno?')).to.deep.equal([])
207 expect(parseChapters('m00:00 super description\nno?')).to.deep.equal([])
208 expect(parseChapters('00:00super description\nno?')).to.deep.equal([])
209 })
210
211 it('Should extract chapters', function () {
212 expect(parseChapters('00:00 coucou')).to.deep.equal([ { timecode: 0, title: 'coucou' } ])
213 expect(parseChapters('my super description\n\n00:01:30 chapter 1\n00:01:35 chapter 2')).to.deep.equal([
214 { timecode: 90, title: 'chapter 1' },
215 { timecode: 95, title: 'chapter 2' }
216 ])
217 expect(parseChapters('hi\n\n00:01:30 chapter 1\n00:01:35 chapter 2\nhi')).to.deep.equal([
218 { timecode: 90, title: 'chapter 1' },
219 { timecode: 95, title: 'chapter 2' }
220 ])
221 expect(parseChapters('hi\n\n00:01:30 chapter 1\n00:01:35 chapter 2\nhi\n00:01:40 chapter 3')).to.deep.equal([
222 { timecode: 90, title: 'chapter 1' },
223 { timecode: 95, title: 'chapter 2' }
224 ])
225 })
226})
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 = {
3 peertube_short: 'https://peertube2.cpy.re/w/3fbif9S3WmtTP8gGsC5HBd', 3 peertube_short: 'https://peertube2.cpy.re/w/3fbif9S3WmtTP8gGsC5HBd',
4 4
5 youtube: 'https://www.youtube.com/watch?v=msX3jv1XdvM', 5 youtube: 'https://www.youtube.com/watch?v=msX3jv1XdvM',
6 youtubeChapters: 'https://www.youtube.com/watch?v=TL9P-Er7ils',
6 7
7 /** 8 /**
8 * The video is used to check format-selection correctness wrt. HDR, 9 * The video is used to check format-selection correctness wrt. HDR,
@@ -26,6 +27,8 @@ const FIXTURE_URLS = {
26 goodVideo: 'https://download.cpy.re/peertube/good_video.mp4', 27 goodVideo: 'https://download.cpy.re/peertube/good_video.mp4',
27 goodVideo720: 'https://download.cpy.re/peertube/good_video_720.mp4', 28 goodVideo720: 'https://download.cpy.re/peertube/good_video_720.mp4',
28 29
30 chatersVideo: 'https://download.cpy.re/peertube/video_chapters.mp4',
31
29 file4K: 'https://download.cpy.re/peertube/4k_file.txt' 32 file4K: 'https://download.cpy.re/peertube/4k_file.txt'
30} 33}
31 34
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, K> = T extends Primitive ? T : DeepOmitHelper<T, Exclude
43export type DeepOmitArray<T extends any[], K> = { 43export type DeepOmitArray<T extends any[], K> = {
44 [P in keyof T]: DeepOmit<T[P], K> 44 [P in keyof T]: DeepOmit<T[P], K>
45} 45}
46
47export type Unpacked<T> = 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 @@
1import cors from 'cors' 1import cors from 'cors'
2import express from 'express' 2import express from 'express'
3import { VideoCommentObject, VideoPlaylistPrivacy, VideoPrivacy, VideoRateType } from '@peertube/peertube-models' 3import {
4 VideoChapterObject,
5 VideoChaptersObject,
6 VideoCommentObject,
7 VideoPlaylistPrivacy,
8 VideoPrivacy,
9 VideoRateType
10} from '@peertube/peertube-models'
4import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js' 11import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js'
5import { getContextFilter } from '@server/lib/activitypub/context.js' 12import { getContextFilter } from '@server/lib/activitypub/context.js'
6import { getServerActor } from '@server/models/application/application.js' 13import { getServerActor } from '@server/models/application/application.js'
@@ -12,12 +19,18 @@ import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/act
12import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js' 19import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js'
13import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js' 20import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js'
14import { 21import {
22 getLocalVideoChaptersActivityPubUrl,
15 getLocalVideoCommentsActivityPubUrl, 23 getLocalVideoCommentsActivityPubUrl,
16 getLocalVideoDislikesActivityPubUrl, 24 getLocalVideoDislikesActivityPubUrl,
17 getLocalVideoLikesActivityPubUrl, 25 getLocalVideoLikesActivityPubUrl,
18 getLocalVideoSharesActivityPubUrl 26 getLocalVideoSharesActivityPubUrl
19} from '../../lib/activitypub/url.js' 27} from '../../lib/activitypub/url.js'
20import { cacheRoute } from '../../middlewares/cache/cache.js' 28import {
29 apVideoChaptersSetCacheKey,
30 buildAPVideoChaptersGroupsCache,
31 cacheRoute,
32 cacheRouteFactory
33} from '../../middlewares/cache/cache.js'
21import { 34import {
22 activityPubRateLimiter, 35 activityPubRateLimiter,
23 asyncMiddleware, 36 asyncMiddleware,
@@ -42,6 +55,8 @@ import { VideoCommentModel } from '../../models/video/video-comment.js'
42import { VideoPlaylistModel } from '../../models/video/video-playlist.js' 55import { VideoPlaylistModel } from '../../models/video/video-playlist.js'
43import { VideoShareModel } from '../../models/video/video-share.js' 56import { VideoShareModel } from '../../models/video/video-share.js'
44import { activityPubResponse } from './utils.js' 57import { activityPubResponse } from './utils.js'
58import { VideoChapterModel } from '@server/models/video/video-chapter.js'
59import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
45 60
46const activityPubClientRouter = express.Router() 61const activityPubClientRouter = express.Router()
47activityPubClientRouter.use(cors()) 62activityPubClientRouter.use(cors())
@@ -145,6 +160,27 @@ activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity
145 asyncMiddleware(videoCommentController) 160 asyncMiddleware(videoCommentController)
146) 161)
147 162
163// ---------------------------------------------------------------------------
164
165const { middleware: chaptersCacheRouteMiddleware, instance: chaptersApiCache } = cacheRouteFactory()
166
167InternalEventEmitter.Instance.on('chapters-updated', ({ video }) => {
168 if (video.remote) return
169
170 chaptersApiCache.clearGroupSafe(buildAPVideoChaptersGroupsCache({ videoId: video.uuid }))
171})
172
173activityPubClientRouter.get('/videos/watch/:id/chapters',
174 executeIfActivityPub,
175 activityPubRateLimiter,
176 apVideoChaptersSetCacheKey,
177 chaptersCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS),
178 asyncMiddleware(videosCustomGetValidator('only-video')),
179 asyncMiddleware(videoChaptersController)
180)
181
182// ---------------------------------------------------------------------------
183
148activityPubClientRouter.get( 184activityPubClientRouter.get(
149 [ '/video-channels/:nameWithHost', '/video-channels/:nameWithHost/videos', '/c/:nameWithHost', '/c/:nameWithHost/videos' ], 185 [ '/video-channels/:nameWithHost', '/video-channels/:nameWithHost/videos', '/c/:nameWithHost', '/c/:nameWithHost/videos' ],
150 executeIfActivityPub, 186 executeIfActivityPub,
@@ -390,6 +426,31 @@ async function videoCommentController (req: express.Request, res: express.Respon
390 return activityPubResponse(activityPubContextify(videoCommentObject, 'Comment', getContextFilter()), res) 426 return activityPubResponse(activityPubContextify(videoCommentObject, 'Comment', getContextFilter()), res)
391} 427}
392 428
429async function videoChaptersController (req: express.Request, res: express.Response) {
430 const video = res.locals.onlyVideo
431
432 if (redirectIfNotOwned(video.url, res)) return
433
434 const chapters = await VideoChapterModel.listChaptersOfVideo(video.id)
435
436 const hasPart: VideoChapterObject[] = []
437
438 if (chapters.length !== 0) {
439 for (let i = 0; i < chapters.length - 1; i++) {
440 hasPart.push(chapters[i].toActivityPubJSON({ video, nextChapter: chapters[i + 1] }))
441 }
442
443 hasPart.push(chapters[chapters.length - 1].toActivityPubJSON({ video: res.locals.onlyVideo, nextChapter: null }))
444 }
445
446 const chaptersObject: VideoChaptersObject = {
447 id: getLocalVideoChaptersActivityPubUrl(video),
448 hasPart
449 }
450
451 return activityPubResponse(activityPubContextify(chaptersObject, 'Chapters', getContextFilter()), res)
452}
453
393async function videoRedundancyController (req: express.Request, res: express.Response) { 454async function videoRedundancyController (req: express.Request, res: express.Response) {
394 const videoRedundancy = res.locals.videoRedundancy 455 const videoRedundancy = res.locals.videoRedundancy
395 456
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 @@
1import express from 'express'
2import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares/index.js'
3import { updateVideoChaptersValidator, videosCustomGetValidator } from '../../../middlewares/validators/index.js'
4import { VideoChapterModel } from '@server/models/video/video-chapter.js'
5import { HttpStatusCode, VideoChapterUpdate } from '@peertube/peertube-models'
6import { sequelizeTypescript } from '@server/initializers/database.js'
7import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
8import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js'
9import { replaceChapters } from '@server/lib/video-chapters.js'
10
11const videoChaptersRouter = express.Router()
12
13videoChaptersRouter.get('/:id/chapters',
14 asyncMiddleware(videosCustomGetValidator('only-video')),
15 asyncMiddleware(listVideoChapters)
16)
17
18videoChaptersRouter.put('/:videoId/chapters',
19 authenticate,
20 asyncMiddleware(updateVideoChaptersValidator),
21 asyncRetryTransactionMiddleware(replaceVideoChapters)
22)
23
24// ---------------------------------------------------------------------------
25
26export {
27 videoChaptersRouter
28}
29
30// ---------------------------------------------------------------------------
31
32async function listVideoChapters (req: express.Request, res: express.Response) {
33 const chapters = await VideoChapterModel.listChaptersOfVideo(res.locals.onlyVideo.id)
34
35 return res.json({ chapters: chapters.map(c => c.toFormattedJSON()) })
36}
37
38async function replaceVideoChapters (req: express.Request, res: express.Response) {
39 const body = req.body as VideoChapterUpdate
40 const video = res.locals.videoAll
41
42 await retryTransactionWrapper(() => {
43 return sequelizeTypescript.transaction(async t => {
44 await replaceChapters({ video, chapters: body.chapters, transaction: t })
45
46 await federateVideoIfNeeded(video, false, t)
47 })
48 })
49
50 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
51}
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'
49import { updateRouter } from './update.js' 49import { updateRouter } from './update.js'
50import { uploadRouter } from './upload.js' 50import { uploadRouter } from './upload.js'
51import { viewRouter } from './view.js' 51import { viewRouter } from './view.js'
52import { videoChaptersRouter } from './chapters.js'
52 53
53const auditLogger = auditLoggerFactory('videos') 54const auditLogger = auditLoggerFactory('videos')
54const videosRouter = express.Router() 55const videosRouter = express.Router()
@@ -73,6 +74,7 @@ videosRouter.use('/', tokenRouter)
73videosRouter.use('/', videoPasswordRouter) 74videosRouter.use('/', videoPasswordRouter)
74videosRouter.use('/', storyboardRouter) 75videosRouter.use('/', storyboardRouter)
75videosRouter.use('/', videoSourceRouter) 76videosRouter.use('/', videoSourceRouter)
77videosRouter.use('/', videoChaptersRouter)
76 78
77videosRouter.get('/categories', 79videosRouter.get('/categories',
78 openapiOperationDoc({ operationId: 'getCategories' }), 80 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'
22import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js' 22import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js'
23import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' 23import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
24import { VideoModel } from '../../../models/video/video.js' 24import { VideoModel } from '../../../models/video/video.js'
25import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
25 26
26const lTags = loggerTagsFactory('api', 'video') 27const lTags = loggerTagsFactory('api', 'video')
27const auditLogger = auditLoggerFactory('videos') 28const auditLogger = auditLoggerFactory('videos')
@@ -67,6 +68,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
67 // Refresh video since thumbnails to prevent concurrent updates 68 // Refresh video since thumbnails to prevent concurrent updates
68 const video = await VideoModel.loadFull(videoFromReq.id, t) 69 const video = await VideoModel.loadFull(videoFromReq.id, t)
69 70
71 const oldDescription = video.description
70 const oldVideoChannel = video.VideoChannel 72 const oldVideoChannel = video.VideoChannel
71 73
72 const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes<VideoModel>)[] = [ 74 const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes<VideoModel>)[] = [
@@ -127,6 +129,15 @@ async function updateVideo (req: express.Request, res: express.Response) {
127 // Schedule an update in the future? 129 // Schedule an update in the future?
128 await updateSchedule(videoInstanceUpdated, videoInfoToUpdate, t) 130 await updateSchedule(videoInstanceUpdated, videoInfoToUpdate, t)
129 131
132 if (oldDescription !== video.description) {
133 await replaceChaptersFromDescriptionIfNeeded({
134 newDescription: videoInstanceUpdated.description,
135 transaction: t,
136 video,
137 oldDescription
138 })
139 }
140
130 await autoBlacklistVideoIfNeeded({ 141 await autoBlacklistVideoIfNeeded({
131 video: videoInstanceUpdated, 142 video: videoInstanceUpdated,
132 user: res.locals.oauth.token.User, 143 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 {
34} from '../../../middlewares/index.js' 34} from '../../../middlewares/index.js'
35import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' 35import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
36import { VideoModel } from '../../../models/video/video.js' 36import { VideoModel } from '../../../models/video/video.js'
37import { getChaptersFromContainer } from '@peertube/peertube-ffmpeg'
38import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
37 39
38const lTags = loggerTagsFactory('api', 'video') 40const lTags = loggerTagsFactory('api', 'video')
39const auditLogger = auditLoggerFactory('videos') 41const auditLogger = auditLoggerFactory('videos')
@@ -143,6 +145,9 @@ async function addVideo (options: {
143 const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' }) 145 const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' })
144 const originalFilename = videoPhysicalFile.originalname 146 const originalFilename = videoPhysicalFile.originalname
145 147
148 const containerChapters = await getChaptersFromContainer(videoPhysicalFile.path)
149 logger.debug(`Got ${containerChapters.length} chapters from video "${video.name}" container`, { containerChapters, ...lTags(video.uuid) })
150
146 // Move physical file 151 // Move physical file
147 const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile) 152 const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
148 await move(videoPhysicalFile.path, destination) 153 await move(videoPhysicalFile.path, destination)
@@ -188,6 +193,10 @@ async function addVideo (options: {
188 }, sequelizeOptions) 193 }, sequelizeOptions)
189 } 194 }
190 195
196 if (!await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction: t })) {
197 await replaceChapters({ video, chapters: containerChapters, transaction: t })
198 }
199
191 await autoBlacklistVideoIfNeeded({ 200 await autoBlacklistVideoIfNeeded({
192 video, 201 video,
193 user, 202 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
79 79
80 uploadDate: 'sc:uploadDate', 80 uploadDate: 'sc:uploadDate',
81 81
82 hasParts: 'sc:hasParts',
83
82 views: { 84 views: {
83 '@type': 'sc:Number', 85 '@type': 'sc:Number',
84 '@id': 'pt:views' 86 '@id': 'pt:views'
@@ -195,7 +197,14 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
195 Announce: buildContext(), 197 Announce: buildContext(),
196 Comment: buildContext(), 198 Comment: buildContext(),
197 Delete: buildContext(), 199 Delete: buildContext(),
198 Rate: buildContext() 200 Rate: buildContext(),
201
202 Chapters: buildContext({
203 name: 'sc:name',
204 hasPart: 'sc:hasPart',
205 endOffset: 'sc:endOffset',
206 startOffset: 'sc:startOffset'
207 })
199} 208}
200 209
201async function getContextData (type: ContextType, contextFilter: ContextFilter) { 210async 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 @@
1import { isArray } from '../misc.js'
2import { isVideoChapterTitleValid, isVideoChapterTimecodeValid } from '../video-chapters.js'
3import { isActivityPubUrlValid } from './misc.js'
4import { VideoChaptersObject } from '@peertube/peertube-models'
5
6export function isVideoChaptersObjectValid (object: VideoChaptersObject) {
7 if (!object) return false
8 if (!isActivityPubUrlValid(object.id)) return false
9
10 if (!isArray(object.hasPart)) return false
11
12 return object.hasPart.every(part => {
13 return isVideoChapterTitleValid(part.name) && isVideoChapterTimecodeValid(part.startOffset)
14 })
15}
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 @@
1import { isArray } from './misc.js'
2import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models'
3import { Unpacked } from '@peertube/peertube-typescript-utils'
4import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
5import validator from 'validator'
6
7export function areVideoChaptersValid (value: VideoChapter[]) {
8 if (!isArray(value)) return false
9 if (!value.every(v => isVideoChapterValid(v))) return false
10
11 const timecodes = value.map(c => c.timecode)
12
13 return new Set(timecodes).size === timecodes.length
14}
15
16export function isVideoChapterValid (value: Unpacked<VideoChapterUpdate['chapters']>) {
17 return isVideoChapterTimecodeValid(value.timecode) && isVideoChapterTitleValid(value.title)
18}
19
20export function isVideoChapterTitleValid (value: any) {
21 return validator.default.isLength(value + '', CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE)
22}
23
24export function isVideoChapterTimecodeValid (value: any) {
25 return validator.default.isInt(value + '', { min: 0 })
26}
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 @@
1import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js' 1import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js'
2import { peertubeTruncate } from '../core-utils.js' 2import { peertubeTruncate } from '../core-utils.js'
3import { isUrlValid } from '../custom-validators/activitypub/misc.js' 3import { isUrlValid } from '../custom-validators/activitypub/misc.js'
4import { isArray } from '../custom-validators/misc.js'
4 5
5export type YoutubeDLInfo = { 6export type YoutubeDLInfo = {
6 name?: string 7 name?: string
@@ -16,6 +17,11 @@ export type YoutubeDLInfo = {
16 webpageUrl?: string 17 webpageUrl?: string
17 18
18 urls?: string[] 19 urls?: string[]
20
21 chapters?: {
22 timecode: number
23 title: string
24 }[]
19} 25}
20 26
21export class YoutubeDLInfoBuilder { 27export class YoutubeDLInfoBuilder {
@@ -83,7 +89,10 @@ export class YoutubeDLInfoBuilder {
83 urls: this.buildAvailableUrl(obj), 89 urls: this.buildAvailableUrl(obj),
84 originallyPublishedAtWithoutTime: this.buildOriginallyPublishedAt(obj), 90 originallyPublishedAtWithoutTime: this.buildOriginallyPublishedAt(obj),
85 ext: obj.ext, 91 ext: obj.ext,
86 webpageUrl: obj.webpage_url 92 webpageUrl: obj.webpage_url,
93 chapters: isArray(obj.chapters)
94 ? obj.chapters.map((c: { start_time: number, title: string }) => ({ timecode: c.start_time, title: c.title }))
95 : []
87 } 96 }
88 } 97 }
89 98
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 = {
465 }, 465 },
466 VIDEO_PASSWORD: { 466 VIDEO_PASSWORD: {
467 LENGTH: { min: 2, max: 100 } 467 LENGTH: { min: 2, max: 100 }
468 },
469 VIDEO_CHAPTERS: {
470 TITLE: { min: 1, max: 100 } // Length
468 } 471 }
469} 472}
470 473
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'
59import { VideoModel } from '../models/video/video.js' 59import { VideoModel } from '../models/video/video.js'
60import { VideoViewModel } from '../models/view/video-view.js' 60import { VideoViewModel } from '../models/view/video-view.js'
61import { CONFIG } from './config.js' 61import { CONFIG } from './config.js'
62import { VideoChapterModel } from '@server/models/video/video-chapter.js'
62 63
63pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string 64pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string
64 65
@@ -137,6 +138,7 @@ async function initDatabaseModels (silent: boolean) {
137 VideoShareModel, 138 VideoShareModel,
138 VideoFileModel, 139 VideoFileModel,
139 VideoSourceModel, 140 VideoSourceModel,
141 VideoChapterModel,
140 VideoCaptionModel, 142 VideoCaptionModel,
141 VideoBlacklistModel, 143 VideoBlacklistModel,
142 VideoTagModel, 144 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) {
80 return video.url + '/comments' 80 return video.url + '/comments'
81} 81}
82 82
83function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
84 return video.url + '/chapters'
85}
86
83function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) { 87function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
84 return video.url + '/likes' 88 return video.url + '/likes'
85} 89}
@@ -167,6 +171,7 @@ export {
167 getDeleteActivityPubUrl, 171 getDeleteActivityPubUrl,
168 getLocalVideoSharesActivityPubUrl, 172 getLocalVideoSharesActivityPubUrl,
169 getLocalVideoCommentsActivityPubUrl, 173 getLocalVideoCommentsActivityPubUrl,
174 getLocalVideoChaptersActivityPubUrl,
170 getLocalVideoLikesActivityPubUrl, 175 getLocalVideoLikesActivityPubUrl,
171 getLocalVideoDislikesActivityPubUrl, 176 getLocalVideoDislikesActivityPubUrl,
172 getLocalVideoViewerActivityPubUrl, 177 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 @@
1import { CreationAttributes, Transaction } from 'sequelize' 1import { CreationAttributes, Transaction } from 'sequelize'
2import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType_Type } from '@peertube/peertube-models' 2import {
3import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils.js' 3 ActivityTagObject,
4 ThumbnailType,
5 VideoChaptersObject,
6 VideoObject,
7 VideoStreamingPlaylistType_Type
8} from '@peertube/peertube-models'
9import { deleteAllModels, filterNonExistingModels, retryTransactionWrapper } from '@server/helpers/database-utils.js'
4import { logger, LoggerTagsFn } from '@server/helpers/logger.js' 10import { logger, LoggerTagsFn } from '@server/helpers/logger.js'
5import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js' 11import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js'
6import { setVideoTags } from '@server/lib/video.js' 12import { setVideoTags } from '@server/lib/video.js'
@@ -29,6 +35,10 @@ import {
29 getThumbnailFromIcons 35 getThumbnailFromIcons
30} from './object-to-model-attributes.js' 36} from './object-to-model-attributes.js'
31import { getTrackerUrls, setVideoTrackers } from './trackers.js' 37import { getTrackerUrls, setVideoTrackers } from './trackers.js'
38import { fetchAP } from '../../activity.js'
39import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js'
40import { sequelizeTypescript } from '@server/initializers/database.js'
41import { replaceChapters } from '@server/lib/video-chapters.js'
32 42
33export abstract class APVideoAbstractBuilder { 43export abstract class APVideoAbstractBuilder {
34 protected abstract videoObject: VideoObject 44 protected abstract videoObject: VideoObject
@@ -44,7 +54,7 @@ export abstract class APVideoAbstractBuilder {
44 protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) { 54 protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) {
45 const miniatureIcon = getThumbnailFromIcons(this.videoObject) 55 const miniatureIcon = getThumbnailFromIcons(this.videoObject)
46 if (!miniatureIcon) { 56 if (!miniatureIcon) {
47 logger.warn('Cannot find thumbnail in video object', { object: this.videoObject }) 57 logger.warn('Cannot find thumbnail in video object', { object: this.videoObject, ...this.lTags() })
48 return undefined 58 return undefined
49 } 59 }
50 60
@@ -138,6 +148,26 @@ export abstract class APVideoAbstractBuilder {
138 video.VideoFiles = await Promise.all(upsertTasks) 148 video.VideoFiles = await Promise.all(upsertTasks)
139 } 149 }
140 150
151 protected async updateChaptersOutsideTransaction (video: MVideoFullLight) {
152 if (!this.videoObject.hasParts || typeof this.videoObject.hasParts !== 'string') return
153
154 const { body } = await fetchAP<VideoChaptersObject>(this.videoObject.hasParts)
155 if (!isVideoChaptersObjectValid(body)) {
156 logger.warn('Chapters AP object is not valid, skipping', { body, ...this.lTags() })
157 return
158 }
159
160 logger.debug('Fetched chapters AP object', { body, ...this.lTags() })
161
162 return retryTransactionWrapper(() => {
163 return sequelizeTypescript.transaction(async t => {
164 const chapters = body.hasPart.map(p => ({ title: p.name, timecode: p.startOffset }))
165
166 await replaceChapters({ chapters, transaction: t, video })
167 })
168 })
169 }
170
141 protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) { 171 protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) {
142 const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject) 172 const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject)
143 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a)) 173 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 {
60 return { autoBlacklisted, videoCreated } 60 return { autoBlacklisted, videoCreated }
61 }) 61 })
62 62
63 await this.updateChaptersOutsideTransaction(videoCreated)
64
63 return { autoBlacklisted, videoCreated } 65 return { autoBlacklisted, videoCreated }
64 } 66 }
65} 67}
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 {
77 77
78 await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t)) 78 await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
79 79
80 await this.updateChaptersOutsideTransaction(videoUpdated)
81
80 await autoBlacklistVideoIfNeeded({ 82 await autoBlacklistVideoIfNeeded({
81 video: videoUpdated, 83 video: videoUpdated,
82 user: undefined, 84 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 @@
1import { MChannel, MVideo } from '@server/types/models/index.js' 1import { MChannel, MVideo, MVideoImmutable } from '@server/types/models/index.js'
2import { EventEmitter } from 'events' 2import { EventEmitter } from 'events'
3 3
4export interface PeerTubeInternalEvents { 4export interface PeerTubeInternalEvents {
@@ -9,6 +9,8 @@ export interface PeerTubeInternalEvents {
9 'channel-created': (options: { channel: MChannel }) => void 9 'channel-created': (options: { channel: MChannel }) => void
10 'channel-updated': (options: { channel: MChannel }) => void 10 'channel-updated': (options: { channel: MChannel }) => void
11 'channel-deleted': (options: { channel: MChannel }) => void 11 'channel-deleted': (options: { channel: MChannel }) => void
12
13 'chapters-updated': (options: { video: MVideoImmutable }) => void
12} 14}
13 15
14declare interface InternalEventEmitter { 16declare 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
32import { getLowercaseExtension } from '@peertube/peertube-node-utils' 32import { getLowercaseExtension } from '@peertube/peertube-node-utils'
33import { 33import {
34 ffprobePromise, 34 ffprobePromise,
35 getChaptersFromContainer,
35 getVideoStreamDimensionsInfo, 36 getVideoStreamDimensionsInfo,
36 getVideoStreamDuration, 37 getVideoStreamDuration,
37 getVideoStreamFPS, 38 getVideoStreamFPS,
@@ -49,6 +50,7 @@ import { federateVideoIfNeeded } from '../../activitypub/videos/index.js'
49import { Notifier } from '../../notifier/index.js' 50import { Notifier } from '../../notifier/index.js'
50import { generateLocalVideoMiniature } from '../../thumbnail.js' 51import { generateLocalVideoMiniature } from '../../thumbnail.js'
51import { JobQueue } from '../job-queue.js' 52import { JobQueue } from '../job-queue.js'
53import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js'
52 54
53async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> { 55async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> {
54 const payload = job.data as VideoImportPayload 56 const payload = job.data as VideoImportPayload
@@ -150,6 +152,8 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
150 const fps = await getVideoStreamFPS(tempVideoPath, probe) 152 const fps = await getVideoStreamFPS(tempVideoPath, probe)
151 const duration = await getVideoStreamDuration(tempVideoPath, probe) 153 const duration = await getVideoStreamDuration(tempVideoPath, probe)
152 154
155 const containerChapters = await getChaptersFromContainer(tempVideoPath, probe)
156
153 // Prepare video file object for creation in database 157 // Prepare video file object for creation in database
154 const fileExt = getLowercaseExtension(tempVideoPath) 158 const fileExt = getLowercaseExtension(tempVideoPath)
155 const videoFileData = { 159 const videoFileData = {
@@ -228,6 +232,8 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
228 if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t) 232 if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t)
229 if (previewModel) await video.addAndSaveThumbnail(previewModel, t) 233 if (previewModel) await video.addAndSaveThumbnail(previewModel, t)
230 234
235 await replaceChaptersIfNotExist({ video, chapters: containerChapters, transaction: t })
236
231 // Now we can federate the video (reload from database, we need more attributes) 237 // Now we can federate the video (reload from database, we need more attributes)
232 const videoForFederation = await VideoModel.loadFull(video.uuid, t) 238 const videoForFederation = await VideoModel.loadFull(video.uuid, t)
233 await federateVideoIfNeeded(videoForFederation, true, t) 239 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 @@
1import { parseChapters, sortBy } from '@peertube/peertube-core-utils'
2import { VideoChapter } from '@peertube/peertube-models'
3import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
4import { VideoChapterModel } from '@server/models/video/video-chapter.js'
5import { MVideoImmutable } from '@server/types/models/index.js'
6import { Transaction } from 'sequelize'
7import { InternalEventEmitter } from './internal-event-emitter.js'
8
9const lTags = loggerTagsFactory('video', 'chapters')
10
11export async function replaceChapters (options: {
12 video: MVideoImmutable
13 chapters: VideoChapter[]
14 transaction: Transaction
15}) {
16 const { chapters, transaction, video } = options
17
18 await VideoChapterModel.deleteChapters(video.id, transaction)
19
20 await createChapters({ videoId: video.id, chapters, transaction })
21
22 InternalEventEmitter.Instance.emit('chapters-updated', { video })
23}
24
25export async function replaceChaptersIfNotExist (options: {
26 video: MVideoImmutable
27 chapters: VideoChapter[]
28 transaction: Transaction
29}) {
30 const { chapters, transaction, video } = options
31
32 if (await VideoChapterModel.hasVideoChapters(video.id, transaction)) return
33
34 await createChapters({ videoId: video.id, chapters, transaction })
35
36 InternalEventEmitter.Instance.emit('chapters-updated', { video })
37}
38
39export async function replaceChaptersFromDescriptionIfNeeded (options: {
40 oldDescription?: string
41 newDescription: string
42 video: MVideoImmutable
43 transaction: Transaction
44}) {
45 const { transaction, video, newDescription, oldDescription = '' } = options
46
47 const chaptersFromOldDescription = sortBy(parseChapters(oldDescription), 'timecode')
48 const existingChapters = await VideoChapterModel.listChaptersOfVideo(video.id, transaction)
49
50 logger.debug(
51 'Check if we replace chapters from description',
52 { oldDescription, chaptersFromOldDescription, newDescription, existingChapters, ...lTags(video.uuid) }
53 )
54
55 // Then we can update chapters from the new description
56 if (areSameChapters(chaptersFromOldDescription, existingChapters)) {
57 const chaptersFromNewDescription = sortBy(parseChapters(newDescription), 'timecode')
58 if (chaptersFromOldDescription.length === 0 && chaptersFromNewDescription.length === 0) return false
59
60 await replaceChapters({ video, chapters: chaptersFromNewDescription, transaction })
61
62 logger.info('Replaced chapters of video ' + video.uuid, { chaptersFromNewDescription, ...lTags(video.uuid) })
63
64 return true
65 }
66
67 return false
68}
69
70// ---------------------------------------------------------------------------
71// Private
72// ---------------------------------------------------------------------------
73
74async function createChapters (options: {
75 videoId: number
76 chapters: VideoChapter[]
77 transaction: Transaction
78}) {
79 const { chapters, transaction, videoId } = options
80
81 for (const chapter of chapters) {
82 await VideoChapterModel.create({
83 title: chapter.title,
84 timecode: chapter.timecode,
85 videoId
86 }, { transaction })
87 }
88}
89
90function areSameChapters (chapters1: VideoChapter[], chapters2: VideoChapter[]) {
91 if (chapters1.length !== chapters2.length) return false
92
93 for (let i = 0; i < chapters1.length; i++) {
94 if (chapters1[i].timecode !== chapters2[i].timecode) return false
95 if (chapters1[i].title !== chapters2[i].title) return false
96 }
97
98 return true
99}
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 {
39} from '@server/types/models/index.js' 39} from '@server/types/models/index.js'
40import { getLocalVideoActivityPubUrl } from './activitypub/url.js' 40import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
41import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js' 41import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js'
42import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js'
42 43
43class YoutubeDlImportError extends Error { 44class YoutubeDlImportError extends Error {
44 code: YoutubeDlImportError.CODE 45 code: YoutubeDlImportError.CODE
@@ -227,6 +228,29 @@ async function buildYoutubeDLImport (options: {
227 videoPasswords: importDataOverride.videoPasswords 228 videoPasswords: importDataOverride.videoPasswords
228 }) 229 })
229 230
231 await sequelizeTypescript.transaction(async transaction => {
232 // Priority to explicitely set description
233 if (importDataOverride?.description) {
234 const inserted = await replaceChaptersFromDescriptionIfNeeded({ newDescription: importDataOverride.description, video, transaction })
235 if (inserted) return
236 }
237
238 // Then priority to youtube-dl chapters
239 if (youtubeDLInfo.chapters.length !== 0) {
240 logger.info(
241 `Inserting chapters in video ${video.uuid} from youtube-dl`,
242 { chapters: youtubeDLInfo.chapters, tags: [ 'chapters', video.uuid ] }
243 )
244
245 await replaceChapters({ video, chapters: youtubeDLInfo.chapters, transaction })
246 return
247 }
248
249 if (video.description) {
250 await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction })
251 }
252 })
253
230 // Get video subtitles 254 // Get video subtitles
231 await processYoutubeSubtitles(youtubeDL, targetUrl, video.id) 255 await processYoutubeSubtitles(youtubeDL, targetUrl, video.id)
232 256
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 @@
1import express from 'express'
1import { HttpStatusCode } from '@peertube/peertube-models' 2import { HttpStatusCode } from '@peertube/peertube-models'
2import { ApiCache, APICacheOptions } from './shared/index.js' 3import { ApiCache, APICacheOptions } from './shared/index.js'
3 4
@@ -8,13 +9,13 @@ const defaultOptions: APICacheOptions = {
8 ] 9 ]
9} 10}
10 11
11function cacheRoute (duration: string) { 12export function cacheRoute (duration: string) {
12 const instance = new ApiCache(defaultOptions) 13 const instance = new ApiCache(defaultOptions)
13 14
14 return instance.buildMiddleware(duration) 15 return instance.buildMiddleware(duration)
15} 16}
16 17
17function cacheRouteFactory (options: APICacheOptions) { 18export function cacheRouteFactory (options: APICacheOptions = {}) {
18 const instance = new ApiCache({ ...defaultOptions, ...options }) 19 const instance = new ApiCache({ ...defaultOptions, ...options })
19 20
20 return { instance, middleware: instance.buildMiddleware.bind(instance) } 21 return { instance, middleware: instance.buildMiddleware.bind(instance) }
@@ -22,17 +23,36 @@ function cacheRouteFactory (options: APICacheOptions) {
22 23
23// --------------------------------------------------------------------------- 24// ---------------------------------------------------------------------------
24 25
25function buildPodcastGroupsCache (options: { 26export function buildPodcastGroupsCache (options: {
26 channelId: number 27 channelId: number
27}) { 28}) {
28 return 'podcast-feed-' + options.channelId 29 return 'podcast-feed-' + options.channelId
29} 30}
30 31
31// --------------------------------------------------------------------------- 32export function buildAPVideoChaptersGroupsCache (options: {
33 videoId: number | string
34}) {
35 return 'ap-video-chapters-' + options.videoId
36}
32 37
33export { 38// ---------------------------------------------------------------------------
34 cacheRoute,
35 cacheRouteFactory,
36 39
37 buildPodcastGroupsCache 40export const videoFeedsPodcastSetCacheKey = [
38} 41 (req: express.Request, res: express.Response, next: express.NextFunction) => {
42 if (req.query.videoChannelId) {
43 res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
44 }
45
46 return next()
47 }
48]
49
50export const apVideoChaptersSetCacheKey = [
51 (req: express.Request, res: express.Response, next: express.NextFunction) => {
52 if (req.params.id) {
53 res.locals.apicacheGroups = [ buildAPVideoChaptersGroupsCache({ videoId: req.params.id }) ]
54 }
55
56 return next()
57 }
58]
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'
3import { HttpStatusCode } from '@peertube/peertube-models' 3import { HttpStatusCode } from '@peertube/peertube-models'
4import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js' 4import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js'
5import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js' 5import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js'
6import { buildPodcastGroupsCache } from '../cache/index.js'
7import { 6import {
8 areValidationErrors, 7 areValidationErrors,
9 checkCanSeeVideo, 8 checkCanSeeVideo,
@@ -114,15 +113,6 @@ const videoFeedsPodcastValidator = [
114 } 113 }
115] 114]
116 115
117const videoFeedsPodcastSetCacheKey = [
118 (req: express.Request, res: express.Response, next: express.NextFunction) => {
119 if (req.query.videoChannelId) {
120 res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
121 }
122
123 return next()
124 }
125]
126// --------------------------------------------------------------------------- 116// ---------------------------------------------------------------------------
127 117
128const videoSubscriptionFeedsValidator = [ 118const videoSubscriptionFeedsValidator = [
@@ -173,6 +163,5 @@ export {
173 feedsAccountOrChannelFiltersValidator, 163 feedsAccountOrChannelFiltersValidator,
174 videoFeedsPodcastValidator, 164 videoFeedsPodcastValidator,
175 videoSubscriptionFeedsValidator, 165 videoSubscriptionFeedsValidator,
176 videoFeedsPodcastSetCacheKey,
177 videoCommentsFeedsValidator 166 videoCommentsFeedsValidator
178} 167}
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'
2export * from './video-captions.js' 2export * from './video-captions.js'
3export * from './video-channel-sync.js' 3export * from './video-channel-sync.js'
4export * from './video-channels.js' 4export * from './video-channels.js'
5export * from './video-chapters.js'
5export * from './video-comments.js' 6export * from './video-comments.js'
6export * from './video-files.js' 7export * from './video-files.js'
7export * from './video-imports.js' 8export * 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 @@
1import express from 'express'
2import { body } from 'express-validator'
3import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
4import {
5 areValidationErrors, checkUserCanManageVideo, doesVideoExist,
6 isValidVideoIdParam
7} from '../shared/index.js'
8import { areVideoChaptersValid } from '@server/helpers/custom-validators/video-chapters.js'
9
10export const updateVideoChaptersValidator = [
11 isValidVideoIdParam('videoId'),
12
13 body('chapters')
14 .custom(areVideoChaptersValid)
15 .withMessage('Chapters must have a valid title and timecode, and each timecode must be unique'),
16
17 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
18 if (areValidationErrors(req, res)) return
19 if (!await doesVideoExist(req.params.videoId, res)) return
20
21 if (res.locals.videoAll.isLive) {
22 return res.fail({
23 status: HttpStatusCode.BAD_REQUEST_400,
24 message: 'You cannot add chapters to a live video'
25 })
26 }
27
28 // Check if the user who did the request is able to update video chapters (same right as updating the video)
29 const user = res.locals.oauth.token.User
30 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
31
32 return next()
33 }
34]
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 {
13} from '@peertube/peertube-models' 13} from '@peertube/peertube-models'
14import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js' 14import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js'
15import { 15import {
16 getLocalVideoChaptersActivityPubUrl,
16 getLocalVideoCommentsActivityPubUrl, 17 getLocalVideoCommentsActivityPubUrl,
17 getLocalVideoDislikesActivityPubUrl, 18 getLocalVideoDislikesActivityPubUrl,
18 getLocalVideoLikesActivityPubUrl, 19 getLocalVideoLikesActivityPubUrl,
@@ -95,6 +96,7 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
95 dislikes: getLocalVideoDislikesActivityPubUrl(video), 96 dislikes: getLocalVideoDislikesActivityPubUrl(video),
96 shares: getLocalVideoSharesActivityPubUrl(video), 97 shares: getLocalVideoSharesActivityPubUrl(video),
97 comments: getLocalVideoCommentsActivityPubUrl(video), 98 comments: getLocalVideoCommentsActivityPubUrl(video),
99 hasParts: getLocalVideoChaptersActivityPubUrl(video),
98 100
99 attributedTo: [ 101 attributedTo: [
100 { 102 {
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 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { MVideo, MVideoChapter } from '@server/types/models/index.js'
3import { VideoChapter, VideoChapterObject } from '@peertube/peertube-models'
4import { AttributesOnly } from '@peertube/peertube-typescript-utils'
5import { VideoModel } from './video.js'
6import { Transaction } from 'sequelize'
7import { getSort } from '../shared/sort.js'
8
9@Table({
10 tableName: 'videoChapter',
11 indexes: [
12 {
13 fields: [ 'videoId', 'timecode' ],
14 unique: true
15 }
16 ]
17})
18export class VideoChapterModel extends Model<Partial<AttributesOnly<VideoChapterModel>>> {
19
20 @AllowNull(false)
21 @Column
22 timecode: number
23
24 @AllowNull(false)
25 @Column
26 title: string
27
28 @ForeignKey(() => VideoModel)
29 @Column
30 videoId: number
31
32 @BelongsTo(() => VideoModel, {
33 foreignKey: {
34 allowNull: false
35 },
36 onDelete: 'CASCADE'
37 })
38 Video: Awaited<VideoModel>
39
40 @CreatedAt
41 createdAt: Date
42
43 @UpdatedAt
44 updatedAt: Date
45
46 static deleteChapters (videoId: number, transaction: Transaction) {
47 const query = {
48 where: {
49 videoId
50 },
51 transaction
52 }
53
54 return VideoChapterModel.destroy(query)
55 }
56
57 static listChaptersOfVideo (videoId: number, transaction?: Transaction) {
58 const query = {
59 where: {
60 videoId
61 },
62 order: getSort('timecode'),
63 transaction
64 }
65
66 return VideoChapterModel.findAll<MVideoChapter>(query)
67 }
68
69 static hasVideoChapters (videoId: number, transaction: Transaction) {
70 return VideoChapterModel.findOne({
71 where: { videoId },
72 transaction
73 }).then(c => !!c)
74 }
75
76 toActivityPubJSON (this: MVideoChapter, options: {
77 video: MVideo
78 nextChapter: MVideoChapter
79 }): VideoChapterObject {
80 return {
81 name: this.title,
82 startOffset: this.timecode,
83 endOffset: options.nextChapter
84 ? options.nextChapter.timecode
85 : options.video.duration
86 }
87 }
88
89 toFormattedJSON (this: MVideoChapter): VideoChapter {
90 return {
91 timecode: this.timecode,
92 title: this.title
93 }
94 }
95}
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 {
14 MActorSummaryFormattable, 14 MActorSummaryFormattable,
15 MActorUrl 15 MActorUrl
16} from '../actor/index.js' 16} from '../actor/index.js'
17import { MChannelDefault } from '../video/video-channels.js' 17import { MChannelDefault } from '../video/video-channel.js'
18import { MAccountBlocklistId } from './account-blocklist.js' 18import { MAccountBlocklistId } from './account-blocklist.js'
19 19
20type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M> 20type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M>
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 {
11 MAccountIdActorId, 11 MAccountIdActorId,
12 MAccountUrl 12 MAccountUrl
13} from '../account/index.js' 13} from '../account/index.js'
14import { MChannelFormattable } from '../video/video-channels.js' 14import { MChannelFormattable } from '../video/video-channel.js'
15import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting.js' 15import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting.js'
16 16
17type Use<K extends keyof UserModel, M> = PickWith<UserModel, K, M> 17type Use<K extends keyof UserModel, M> = PickWith<UserModel, K, M>
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'
10export * from './video-caption.js' 10export * from './video-caption.js'
11export * from './video-change-ownership.js' 11export * from './video-change-ownership.js'
12export * from './video-channel-sync.js' 12export * from './video-channel-sync.js'
13export * from './video-channels.js' 13export * from './video-channel.js'
14export * from './video-chapter.js'
14export * from './video-comment.js' 15export * from './video-comment.js'
15export * from './video-file.js' 16export * from './video-file.js'
16export * from './video-import.js' 17export * 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 @@
1import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js' 1import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
2import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils' 2import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils'
3import { MChannelAccountDefault, MChannelFormattable } from './video-channels.js' 3import { MChannelAccountDefault, MChannelFormattable } from './video-channel.js'
4 4
5type Use<K extends keyof VideoChannelSyncModel, M> = PickWith<VideoChannelSyncModel, K, M> 5type Use<K extends keyof VideoChannelSyncModel, M> = PickWith<VideoChannelSyncModel, K, M>
6 6
diff --git a/server/server/types/models/video/video-channels.ts b/server/server/types/models/video/video-channel.ts
index e8cb9cb26..e8cb9cb26 100644
--- a/server/server/types/models/video/video-channels.ts
+++ b/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 @@
1import { VideoChapterModel } from '@server/models/video/video-chapter.js'
2
3export type MVideoChapter = Omit<VideoChapterModel, 'Video'>
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'
3import { VideoPlaylistModel } from '../../../models/video/video-playlist.js' 3import { VideoPlaylistModel } from '../../../models/video/video-playlist.js'
4import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account/index.js' 4import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account/index.js'
5import { MThumbnail } from './thumbnail.js' 5import { MThumbnail } from './thumbnail.js'
6import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channels.js' 6import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channel.js'
7 7
8type Use<K extends keyof VideoPlaylistModel, M> = PickWith<VideoPlaylistModel, K, M> 8type Use<K extends keyof VideoPlaylistModel, M> = PickWith<VideoPlaylistModel, K, M>
9 9
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 {
16 MChannelFormattable, 16 MChannelFormattable,
17 MChannelHostOnly, 17 MChannelHostOnly,
18 MChannelUserId 18 MChannelUserId
19} from './video-channels.js' 19} from './video-channel.js'
20import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js' 20import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js'
21import { MVideoLive } from './video-live.js' 21import { MVideoLive } from './video-live.js'
22import { 22import {
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:
257 description: Operations dealing with synchronizing PeerTube user's channel with channels of other platforms 257 description: Operations dealing with synchronizing PeerTube user's channel with channels of other platforms
258 - name: Video Captions 258 - name: Video Captions
259 description: Operations dealing with listing, adding and removing closed captions of a video. 259 description: Operations dealing with listing, adding and removing closed captions of a video.
260 - name: Video Chapters
261 description: Operations dealing with managing chapters of a video.
260 - name: Video Channels 262 - name: Video Channels
261 description: Operations dealing with the creation, modification and listing of videos within a channel. 263 description: Operations dealing with the creation, modification and listing of videos within a channel.
262 - name: Video Comments 264 - name: Video Comments
@@ -328,6 +330,7 @@ x-tagGroups:
328 - Video Upload 330 - Video Upload
329 - Video Imports 331 - Video Imports
330 - Video Captions 332 - Video Captions
333 - Video Chapters
331 - Video Channels 334 - Video Channels
332 - Video Comments 335 - Video Comments
333 - Video Rates 336 - Video Rates
@@ -3242,7 +3245,7 @@ paths:
3242 '/api/v1/videos/{id}/source/replace-resumable': 3245 '/api/v1/videos/{id}/source/replace-resumable':
3243 post: 3246 post:
3244 summary: Initialize the resumable replacement of a video 3247 summary: Initialize the resumable replacement of a video
3245 description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to initialize the replacement of a video 3248 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"
3246 operationId: replaceVideoSourceResumableInit 3249 operationId: replaceVideoSourceResumableInit
3247 security: 3250 security:
3248 - OAuth2: [] 3251 - OAuth2: []
@@ -3281,7 +3284,7 @@ paths:
3281 description: video type unsupported 3284 description: video type unsupported
3282 put: 3285 put:
3283 summary: Send chunk for the resumable replacement of a video 3286 summary: Send chunk for the resumable replacement of a video
3284 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 3287 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"
3285 operationId: replaceVideoSourceResumable 3288 operationId: replaceVideoSourceResumable
3286 security: 3289 security:
3287 - OAuth2: [] 3290 - OAuth2: []
@@ -3331,7 +3334,7 @@ paths:
3331 example: 300 3334 example: 300
3332 delete: 3335 delete:
3333 summary: Cancel the resumable replacement of a video 3336 summary: Cancel the resumable replacement of a video
3334 description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to cancel the replacement of a video 3337 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"
3335 operationId: replaceVideoSourceResumableCancel 3338 operationId: replaceVideoSourceResumableCancel
3336 security: 3339 security:
3337 - OAuth2: [] 3340 - OAuth2: []
@@ -3742,6 +3745,7 @@ paths:
3742 /api/v1/videos/{id}/storyboards: 3745 /api/v1/videos/{id}/storyboards:
3743 get: 3746 get:
3744 summary: List storyboards of a video 3747 summary: List storyboards of a video
3748 description: "**PeerTube** >= 6.0"
3745 operationId: listVideoStoryboards 3749 operationId: listVideoStoryboards
3746 tags: 3750 tags:
3747 - Video 3751 - Video
@@ -3832,9 +3836,59 @@ paths:
3832 '404': 3836 '404':
3833 description: video or language or caption for that language not found 3837 description: video or language or caption for that language not found
3834 3838
3839 /api/v1/videos/{id}/chapters:
3840 get:
3841 summary: Get chapters of a video
3842 description: "**PeerTube** >= 6.0"
3843 operationId: getVideoChapters
3844 tags:
3845 - Video Chapters
3846 parameters:
3847 - $ref: '#/components/parameters/idOrUUID'
3848 - $ref: '#/components/parameters/videoPasswordHeader'
3849 responses:
3850 '200':
3851 description: successful operation
3852 content:
3853 application/json:
3854 schema:
3855 $ref: '#/components/schemas/VideoChapters'
3856 put:
3857 summary: Replace video chapters
3858 description: "**PeerTube** >= 6.0"
3859 operationId: replaceVideoChapters
3860 security:
3861 - OAuth2:
3862 - user
3863 tags:
3864 - Video Chapters
3865 parameters:
3866 - $ref: '#/components/parameters/idOrUUID'
3867 requestBody:
3868 content:
3869 application/json:
3870 schema:
3871 type: object
3872 properties:
3873 chapters:
3874 type: array
3875 items:
3876 type: object
3877 properties:
3878 title:
3879 type: string
3880 timecode:
3881 type: integer
3882 responses:
3883 '204':
3884 description: successful operation
3885 '404':
3886 description: video not found
3887
3835 /api/v1/videos/{id}/passwords: 3888 /api/v1/videos/{id}/passwords:
3836 get: 3889 get:
3837 summary: List video passwords 3890 summary: List video passwords
3891 description: "**PeerTube** >= 6.0"
3838 security: 3892 security:
3839 - OAuth2: 3893 - OAuth2:
3840 - user 3894 - user
@@ -3856,6 +3910,7 @@ paths:
3856 description: video is not password protected 3910 description: video is not password protected
3857 put: 3911 put:
3858 summary: Update video passwords 3912 summary: Update video passwords
3913 description: "**PeerTube** >= 6.0"
3859 security: 3914 security:
3860 - OAuth2: 3915 - OAuth2:
3861 - user 3916 - user
@@ -3880,6 +3935,7 @@ paths:
3880 /api/v1/videos/{id}/passwords/{videoPasswordId}: 3935 /api/v1/videos/{id}/passwords/{videoPasswordId}:
3881 delete: 3936 delete:
3882 summary: Delete a video password 3937 summary: Delete a video password
3938 description: "**PeerTube** >= 6.0"
3883 security: 3939 security:
3884 - OAuth2: 3940 - OAuth2:
3885 - user 3941 - user
@@ -7704,6 +7760,15 @@ components:
7704 $ref: '#/components/schemas/VideoConstantString-Language' 7760 $ref: '#/components/schemas/VideoConstantString-Language'
7705 captionPath: 7761 captionPath:
7706 type: string 7762 type: string
7763 VideoChapters:
7764 properties:
7765 chapters:
7766 type: object
7767 properties:
7768 title:
7769 type: string
7770 timecode:
7771 type: integer
7707 VideoSource: 7772 VideoSource:
7708 properties: 7773 properties:
7709 filename: 7774 filename: