diff options
author | Chocobozzz <me@florianbigard.com> | 2023-08-28 10:55:04 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2023-08-28 16:17:31 +0200 |
commit | 77b70702d2193d78bf6fbd07f0fc7335e34957f8 (patch) | |
tree | 1a0aed540054286c9a8b10c4890cc0f718e00458 | |
parent | 7113f32a87bd6b2868154fed20bde1a1633c190e (diff) | |
download | PeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.tar.gz PeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.tar.zst PeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.zip |
Add video chapters support
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' | |||
2 | import { map } from 'rxjs/operators' | 2 | import { map } from 'rxjs/operators' |
3 | import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model' | 3 | import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model' |
4 | import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' | 4 | import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' |
5 | import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms' | 5 | import { AbstractControl, FormArray, FormGroup, Validators } from '@angular/forms' |
6 | import { HooksService, PluginService, ServerService } from '@app/core' | 6 | import { HooksService, PluginService, ServerService } from '@app/core' |
7 | import { removeElementFromArray } from '@app/helpers' | 7 | import { removeElementFromArray } from '@app/helpers' |
8 | import { BuildFormValidator } from '@app/shared/form-validators' | 8 | import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators' |
9 | import { | 9 | import { |
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' |
23 | import { FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms' | 23 | import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators' |
24 | import { FormReactiveErrors, FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms' | ||
24 | import { InstanceService } from '@app/shared/shared-instance' | 25 | import { InstanceService } from '@app/shared/shared-instance' |
25 | import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoEdit, VideoService } from '@app/shared/shared-main' | 26 | import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoChaptersEdit, VideoEdit, VideoService } from '@app/shared/shared-main' |
26 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 27 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
27 | import { | 28 | import { |
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 | }) |
58 | export class VideoEditComponent implements OnInit, OnDestroy { | 60 | export 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' | |||
4 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' | 4 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' |
5 | import { scrollToTop } from '@app/helpers' | 5 | import { scrollToTop } from '@app/helpers' |
6 | import { FormReactiveService } from '@app/shared/shared-forms' | 6 | import { FormReactiveService } from '@app/shared/shared-forms' |
7 | import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' | 7 | import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main' |
8 | import { LiveVideoService } from '@app/shared/shared-video-live' | 8 | import { LiveVideoService } from '@app/shared/shared-video-live' |
9 | import { LoadingBarService } from '@ngx-loading-bar/core' | 9 | import { LoadingBarService } from '@ngx-loading-bar/core' |
10 | import { logger } from '@root-helpers/logger' | 10 | import { 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' | |||
4 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' | 4 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' |
5 | import { scrollToTop } from '@app/helpers' | 5 | import { scrollToTop } from '@app/helpers' |
6 | import { FormReactiveService } from '@app/shared/shared-forms' | 6 | import { FormReactiveService } from '@app/shared/shared-forms' |
7 | import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' | 7 | import { VideoCaptionService, VideoChapterService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' |
8 | import { LoadingBarService } from '@ngx-loading-bar/core' | 8 | import { LoadingBarService } from '@ngx-loading-bar/core' |
9 | import { logger } from '@root-helpers/logger' | 9 | import { logger } from '@root-helpers/logger' |
10 | import { PeerTubeProblemDocument, ServerErrorCode, VideoUpdate } from '@peertube/peertube-models' | 10 | import { 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 @@ | |||
1 | import { forkJoin } from 'rxjs' | 1 | import { forkJoin } from 'rxjs' |
2 | import { map, switchMap } from 'rxjs/operators' | 2 | import { map, switchMap } from 'rxjs/operators' |
3 | import { AfterViewInit, Component, EventEmitter, OnInit, Output } from '@angular/core' | 3 | import { AfterViewInit, Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' |
4 | import { Router } from '@angular/router' | 4 | import { Router } from '@angular/router' |
5 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' | 5 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' |
6 | import { scrollToTop } from '@app/helpers' | 6 | import { scrollToTop } from '@app/helpers' |
7 | import { FormReactiveService } from '@app/shared/shared-forms' | 7 | import { FormReactiveService } from '@app/shared/shared-forms' |
8 | import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' | 8 | import { VideoCaptionService, VideoChapterService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' |
9 | import { LoadingBarService } from '@ngx-loading-bar/core' | 9 | import { LoadingBarService } from '@ngx-loading-bar/core' |
10 | import { logger } from '@root-helpers/logger' | 10 | import { logger } from '@root-helpers/logger' |
11 | import { VideoUpdate } from '@peertube/peertube-models' | 11 | import { VideoUpdate } from '@peertube/peertube-models' |
12 | import { hydrateFormFromVideo } from '../shared/video-edit-utils' | 12 | import { hydrateFormFromVideo } from '../shared/video-edit-utils' |
13 | import { VideoSend } from './video-send' | 13 | import { VideoSend } from './video-send' |
14 | import { 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 | }) |
23 | export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterViewInit, CanComponentDeactivate { | 24 | export 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' | |||
4 | import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core' | 4 | import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core' |
5 | import { listUserChannelsForSelect } from '@app/helpers' | 5 | import { listUserChannelsForSelect } from '@app/helpers' |
6 | import { FormReactive } from '@app/shared/shared-forms' | 6 | import { FormReactive } from '@app/shared/shared-forms' |
7 | import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' | 7 | import { |
8 | VideoCaptionEdit, | ||
9 | VideoCaptionService, | ||
10 | VideoChapterService, | ||
11 | VideoChaptersEdit, | ||
12 | VideoEdit, | ||
13 | VideoService | ||
14 | } from '@app/shared/shared-main' | ||
8 | import { LoadingBarService } from '@ngx-loading-bar/core' | 15 | import { LoadingBarService } from '@ngx-loading-bar/core' |
9 | import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models' | 16 | import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models' |
17 | import { 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' | |||
7 | import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core' | 7 | import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core' |
8 | import { genericUploadErrorHandler, scrollToTop } from '@app/helpers' | 8 | import { genericUploadErrorHandler, scrollToTop } from '@app/helpers' |
9 | import { FormReactiveService } from '@app/shared/shared-forms' | 9 | import { FormReactiveService } from '@app/shared/shared-forms' |
10 | import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' | 10 | import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main' |
11 | import { LoadingBarService } from '@ngx-loading-bar/core' | 11 | import { LoadingBarService } from '@ngx-loading-bar/core' |
12 | import { logger } from '@root-helpers/logger' | 12 | import { logger } from '@root-helpers/logger' |
13 | import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models' | 13 | import { 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' | |||
4 | import { catchError, map, switchMap } from 'rxjs/operators' | 4 | import { catchError, map, switchMap } from 'rxjs/operators' |
5 | import { SelectChannelItem } from 'src/types/select-options-item.model' | 5 | import { SelectChannelItem } from 'src/types/select-options-item.model' |
6 | import { HttpErrorResponse } from '@angular/common/http' | 6 | import { HttpErrorResponse } from '@angular/common/http' |
7 | import { Component, HostListener, OnDestroy, OnInit } from '@angular/core' | 7 | import { Component, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core' |
8 | import { ActivatedRoute, Router } from '@angular/router' | 8 | import { ActivatedRoute, Router } from '@angular/router' |
9 | import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core' | 9 | import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core' |
10 | import { genericUploadErrorHandler } from '@app/helpers' | 10 | import { genericUploadErrorHandler } from '@app/helpers' |
11 | import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' | 11 | import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' |
12 | import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' | 12 | import { |
13 | Video, | ||
14 | VideoCaptionEdit, | ||
15 | VideoCaptionService, | ||
16 | VideoChapterService, | ||
17 | VideoChaptersEdit, | ||
18 | VideoDetails, | ||
19 | VideoEdit, | ||
20 | VideoService | ||
21 | } from '@app/shared/shared-main' | ||
13 | import { LiveVideoService } from '@app/shared/shared-video-live' | 22 | import { LiveVideoService } from '@app/shared/shared-video-live' |
14 | import { LoadingBarService } from '@ngx-loading-bar/core' | 23 | import { LoadingBarService } from '@ngx-loading-bar/core' |
15 | import { pick, simpleObjectsDeepEqual } from '@peertube/peertube-core-utils' | 24 | import { pick, simpleObjectsDeepEqual } from '@peertube/peertube-core-utils' |
16 | import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoSource, VideoState } from '@peertube/peertube-models' | 25 | import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoSource, VideoState } from '@peertube/peertube-models' |
17 | import { hydrateFormFromVideo } from './shared/video-edit-utils' | 26 | import { hydrateFormFromVideo } from './shared/video-edit-utils' |
18 | import { VideoUploadService } from './shared/video-upload.service' | 27 | import { VideoUploadService } from './shared/video-upload.service' |
28 | import { VideoEditComponent } from './shared/video-edit.component' | ||
19 | 29 | ||
20 | const debugLogger = debug('peertube:video-update') | 30 | const 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 | }) |
27 | export class VideoUpdateComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate { | 37 | export 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' | |||
4 | import { ActivatedRouteSnapshot } from '@angular/router' | 4 | import { ActivatedRouteSnapshot } from '@angular/router' |
5 | import { AuthService } from '@app/core' | 5 | import { AuthService } from '@app/core' |
6 | import { listUserChannelsForSelect } from '@app/helpers' | 6 | import { listUserChannelsForSelect } from '@app/helpers' |
7 | import { VideoCaptionService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main' | 7 | import { VideoCaptionService, VideoChapterService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main' |
8 | import { LiveVideoService } from '@app/shared/shared-video-live' | 8 | import { LiveVideoService } from '@app/shared/shared-video-live' |
9 | import { VideoPrivacy } from '@peertube/peertube-models' | 9 | import { 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' |
19 | import { HooksService } from '@app/core/plugins/hooks.service' | 19 | import { HooksService } from '@app/core/plugins/hooks.service' |
20 | import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers' | 20 | import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers' |
21 | import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' | 21 | import { Video, VideoCaptionService, VideoChapterService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' |
22 | import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' | 22 | import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' |
23 | import { LiveVideoService } from '@app/shared/shared-video-live' | 23 | import { LiveVideoService } from '@app/shared/shared-video-live' |
24 | import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' | 24 | import { 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 | ||
10 | function 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 | |||
21 | function splitIntoArray (value: any) { | 10 | function 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 | ||
43 | export { | 32 | export { |
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 @@ | |||
1 | import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' | 1 | import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' |
2 | import { getDevLocale, isOnDevLocale, sortBy } from '@app/helpers' | 2 | import { getDevLocale, isOnDevLocale } from '@app/helpers' |
3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
4 | import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped } from '@peertube/peertube-core-utils' | 4 | import { 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 @@ | |||
1 | import { AbstractControl, ValidationErrors, ValidatorFn, Validators } from '@angular/forms' | ||
2 | import { BuildFormValidator } from './form-validator.model' | ||
3 | |||
4 | export 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 | |||
13 | export const VIDEO_CHAPTERS_ARRAY_VALIDATOR: BuildFormValidator = { | ||
14 | VALIDATORS: [ uniqueTimecodeValidator() ], | ||
15 | MESSAGES: {} | ||
16 | } | ||
17 | |||
18 | function 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 | ||
73 | export 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 | |||
81 | export const VIDEO_TAGS_ARRAY_VALIDATOR: BuildFormValidator = { | 73 | export 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' | |||
4 | import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' | 4 | import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' |
5 | import { FormValidatorService } from './form-validator.service' | 5 | import { FormValidatorService } from './form-validator.service' |
6 | 6 | ||
7 | export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors } | 7 | export type FormReactiveErrors = { [ id: string | number ]: string | FormReactiveErrors | FormReactiveErrors[] } |
8 | export type FormReactiveValidationMessages = { | 8 | export 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 @@ | |||
1 | import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core' | 1 | import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core' |
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' |
3 | import { Notifier } from '@app/core' | 3 | import { 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' | |||
6 | import { SafeHtml } from '@angular/platform-browser' | 6 | import { SafeHtml } from '@angular/platform-browser' |
7 | import { MarkdownService, ScreenService } from '@app/core' | 7 | import { MarkdownService, ScreenService } from '@app/core' |
8 | import { Video } from '@peertube/peertube-models' | 8 | import { Video } from '@peertube/peertube-models' |
9 | import { 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' | |||
23 | export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { | 24 | export 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 @@ | |||
4 | p-inputmask { | 4 | p-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 | |||
49 | import { | 49 | import { |
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' | |||
3 | import { HttpClient } from '@angular/common/http' | 3 | import { HttpClient } from '@angular/common/http' |
4 | import { Injectable } from '@angular/core' | 4 | import { Injectable } from '@angular/core' |
5 | import { RestExtractor, ServerService } from '@app/core' | 5 | import { RestExtractor, ServerService } from '@app/core' |
6 | import { objectToFormData, sortBy } from '@app/helpers' | 6 | import { objectToFormData } from '@app/helpers' |
7 | import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video' | 7 | import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video' |
8 | import { peertubeTranslate } from '@peertube/peertube-core-utils' | 8 | import { peertubeTranslate, sortBy } from '@peertube/peertube-core-utils' |
9 | import { ResultList, VideoCaption } from '@peertube/peertube-models' | 9 | import { ResultList, VideoCaption } from '@peertube/peertube-models' |
10 | import { environment } from '../../../../environments/environment' | 10 | import { environment } from '../../../../environments/environment' |
11 | import { VideoCaptionEdit } from './video-caption-edit.model' | 11 | import { VideoCaptionEdit } from './video-caption-edit.model' |
diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts index 07d40b117..7414ded23 100644 --- a/client/src/app/shared/shared-main/video/index.ts +++ b/client/src/app/shared/shared-main/video/index.ts | |||
@@ -1,5 +1,7 @@ | |||
1 | export * from './embed.component' | 1 | export * from './embed.component' |
2 | export * from './redundancy.service' | 2 | export * from './redundancy.service' |
3 | export * from './video-chapter.service' | ||
4 | export * from './video-chapters-edit.model' | ||
3 | export * from './video-details.model' | 5 | export * from './video-details.model' |
4 | export * from './video-edit.model' | 6 | export * from './video-edit.model' |
5 | export * from './video-file-token.service' | 7 | export * from './video-file-token.service' |
diff --git a/client/src/app/shared/shared-main/video/video-chapter.service.ts b/client/src/app/shared/shared-main/video/video-chapter.service.ts new file mode 100644 index 000000000..6d221c9e9 --- /dev/null +++ b/client/src/app/shared/shared-main/video/video-chapter.service.ts | |||
@@ -0,0 +1,34 @@ | |||
1 | import { catchError } from 'rxjs/operators' | ||
2 | import { HttpClient } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { RestExtractor } from '@app/core' | ||
5 | import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models' | ||
6 | import { VideoPasswordService } from './video-password.service' | ||
7 | import { VideoService } from './video.service' | ||
8 | import { VideoChaptersEdit } from './video-chapters-edit.model' | ||
9 | import { of } from 'rxjs' | ||
10 | |||
11 | @Injectable() | ||
12 | export 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 @@ | |||
1 | import { simpleObjectsDeepEqual, sortBy } from '@peertube/peertube-core-utils' | ||
2 | import { VideoChapter } from '@peertube/peertube-models' | ||
3 | |||
4 | export 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' | |||
7 | import './shared/peertube/peertube-plugin' | 7 | import './shared/peertube/peertube-plugin' |
8 | import './shared/resolutions/peertube-resolutions-plugin' | 8 | import './shared/resolutions/peertube-resolutions-plugin' |
9 | import './shared/control-bar/storyboard-plugin' | 9 | import './shared/control-bar/storyboard-plugin' |
10 | import './shared/control-bar/chapters-plugin' | ||
11 | import './shared/control-bar/time-tooltip' | ||
10 | import './shared/control-bar/next-previous-video-button' | 12 | import './shared/control-bar/next-previous-video-button' |
11 | import './shared/control-bar/p2p-info-button' | 13 | import './shared/control-bar/p2p-info-button' |
12 | import './shared/control-bar/peertube-link-button' | 14 | import './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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { ChaptersOptions } from '../../types' | ||
3 | import { VideoChapter } from '@peertube/peertube-models' | ||
4 | import { ProgressBarMarkerComponent } from './progress-bar-marker-component' | ||
5 | |||
6 | const Plugin = videojs.getPlugin('plugin') | ||
7 | |||
8 | class 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 | |||
62 | videojs.registerPlugin('chapters', ChaptersPlugin) | ||
63 | |||
64 | export { ChaptersPlugin } | ||
diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts index 9307027f6..091e876e2 100644 --- a/client/src/assets/player/shared/control-bar/index.ts +++ b/client/src/assets/player/shared/control-bar/index.ts | |||
@@ -1,6 +1,8 @@ | |||
1 | export * from './chapters-plugin' | ||
1 | export * from './next-previous-video-button' | 2 | export * from './next-previous-video-button' |
2 | export * from './p2p-info-button' | 3 | export * from './p2p-info-button' |
3 | export * from './peertube-link-button' | 4 | export * from './peertube-link-button' |
4 | export * from './peertube-live-display' | 5 | export * from './peertube-live-display' |
5 | export * from './storyboard-plugin' | 6 | export * from './storyboard-plugin' |
6 | export * from './theater-button' | 7 | export * from './theater-button' |
8 | export * from './time-tooltip' | ||
diff --git a/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts b/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts new file mode 100644 index 000000000..50965ec71 --- /dev/null +++ b/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts | |||
@@ -0,0 +1,24 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { ProgressBarMarkerComponentOptions } from '../../types' | ||
3 | |||
4 | const Component = videojs.getComponent('Component') | ||
5 | |||
6 | export 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 | |||
24 | videojs.registerComponent('ProgressBarMarkerComponent', ProgressBarMarkerComponent) | ||
diff --git a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts index 80c69b5f2..91d7f451e 100644 --- a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts +++ b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts | |||
@@ -141,7 +141,9 @@ class StoryboardPlugin extends Plugin { | |||
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 @@ | |||
1 | import { timeToInt } from '@peertube/peertube-core-utils' | ||
2 | import videojs, { VideoJsPlayer } from 'video.js' | ||
3 | |||
4 | const TimeToolTip = videojs.getComponent('TimeTooltip') as any // FIXME: typings don't have write method | ||
5 | |||
6 | class 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 | |||
20 | videojs.registerComponent('TimeTooltip', TimeTooltip) | ||
diff --git a/client/src/assets/player/types/peertube-player-options.ts b/client/src/assets/player/types/peertube-player-options.ts index 6fb2f7913..32f26fa9e 100644 --- a/client/src/assets/player/types/peertube-player-options.ts +++ b/client/src/assets/player/types/peertube-player-options.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { LiveVideoLatencyModeType, VideoFile } from '@peertube/peertube-models' | 1 | import { LiveVideoLatencyModeType, VideoChapter, VideoFile } from '@peertube/peertube-models' |
2 | import { PluginsManager } from '@root-helpers/plugins-manager' | 2 | import { PluginsManager } from '@root-helpers/plugins-manager' |
3 | import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' | 3 | import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' |
4 | import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' | 4 | import { 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 @@ | |||
1 | import { HlsConfig, Level } from 'hls.js' | 1 | import { HlsConfig, Level } from 'hls.js' |
2 | import videojs from 'video.js' | 2 | import videojs from 'video.js' |
3 | import { Engine } from '@peertube/p2p-media-loader-hlsjs' | 3 | import { Engine } from '@peertube/p2p-media-loader-hlsjs' |
4 | import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' | 4 | import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' |
5 | import { BezelsPlugin } from '../shared/bezels/bezels-plugin' | 5 | import { BezelsPlugin } from '../shared/bezels/bezels-plugin' |
6 | import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' | 6 | import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' |
7 | import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' | 7 | import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' |
@@ -19,6 +19,7 @@ import { UpNextPlugin } from '../shared/upnext/upnext-plugin' | |||
19 | import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' | 19 | import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' |
20 | import { PlayerMode } from './peertube-player-options' | 20 | import { PlayerMode } from './peertube-player-options' |
21 | import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' | 21 | import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' |
22 | import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin' | ||
22 | 23 | ||
23 | declare module 'video.js' { | 24 | declare 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 | ||
148 | type ChaptersOptions = { | ||
149 | chapters: VideoChapter[] | ||
150 | } | ||
151 | |||
145 | type PlaylistPluginOptions = { | 152 | type 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 | ||
171 | type ProgressBarMarkerComponentOptions = { | ||
172 | timecode: number | ||
173 | } | ||
174 | |||
164 | type NextPreviousVideoButtonOptions = { | 175 | type 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 @@ | |||
1 | function findCommonElement <T> (array1: T[], array2: T[]) { | 1 | export 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 |
12 | function arrayify <T> (element: T | T[]) { | 12 | export 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 |
19 | function uniqify <T> (elements: T[]) { | 19 | export 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 |
24 | function shuffle <T> (elements: T[]) { | 24 | export 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 | ||
36 | export { | 36 | export 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 | ||
48 | export const timecodeRegexString = `((\\d+)[h:])?((\\d+)[m:])?((\\d+)s?)?` | ||
49 | |||
48 | function timeToInt (time: number | string) { | 50 | function 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' | |||
5 | export * from './renderer/index.js' | 5 | export * from './renderer/index.js' |
6 | export * from './users/index.js' | 6 | export * from './users/index.js' |
7 | export * from './videos/index.js' | 7 | export * from './videos/index.js' |
8 | export * from './string/index.js' | ||
diff --git a/packages/core-utils/src/string/chapters.ts b/packages/core-utils/src/string/chapters.ts new file mode 100644 index 000000000..d7643665c --- /dev/null +++ b/packages/core-utils/src/string/chapters.ts | |||
@@ -0,0 +1,32 @@ | |||
1 | import { timeToInt, timecodeRegexString } from '../common/date.js' | ||
2 | |||
3 | const timecodeRegex = new RegExp(`^(${timecodeRegexString})\\s`) | ||
4 | |||
5 | export 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 | ||
11 | function ffprobePromise (path: string) { | 11 | function 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 | |||
175 | async 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 | ||
173 | export { | 189 | export { |
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' | |||
4 | export * from './common-objects.js' | 4 | export * from './common-objects.js' |
5 | export * from './playlist-element-object.js' | 5 | export * from './playlist-element-object.js' |
6 | export * from './playlist-object.js' | 6 | export * from './playlist-object.js' |
7 | export * from './video-chapters-object.js' | ||
7 | export * from './video-comment-object.js' | 8 | export * from './video-comment-object.js' |
8 | export * from './video-object.js' | 9 | export * from './video-object.js' |
9 | export * from './watch-action-object.js' | 10 | export * from './watch-action-object.js' |
diff --git a/packages/models/src/activitypub/objects/video-chapters-object.ts b/packages/models/src/activitypub/objects/video-chapters-object.ts new file mode 100644 index 000000000..0149c6e87 --- /dev/null +++ b/packages/models/src/activitypub/objects/video-chapters-object.ts | |||
@@ -0,0 +1,11 @@ | |||
1 | export interface VideoChaptersObject { | ||
2 | id: string | ||
3 | hasPart: VideoChapterObject[] | ||
4 | } | ||
5 | |||
6 | // Same as https://schema.org/hasPart | ||
7 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export * from './chapter-update.model.js' | ||
2 | export * from './chapter.model.js' | ||
diff --git a/packages/models/src/videos/index.ts b/packages/models/src/videos/index.ts index d131212c9..7d96d31a6 100644 --- a/packages/models/src/videos/index.ts +++ b/packages/models/src/videos/index.ts | |||
@@ -12,6 +12,7 @@ export * from './rate/index.js' | |||
12 | export * from './stats/index.js' | 12 | export * from './stats/index.js' |
13 | export * from './transcoding/index.js' | 13 | export * from './transcoding/index.js' |
14 | export * from './channel-sync/index.js' | 14 | export * from './channel-sync/index.js' |
15 | export * from './chapter/index.js' | ||
15 | 16 | ||
16 | export * from './nsfw-policy.type.js' | 17 | export * 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 @@ | |||
1 | import { | ||
2 | HttpStatusCode, VideoChapterUpdate, VideoChapters | ||
3 | } from '@peertube/peertube-models' | ||
4 | import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' | ||
5 | |||
6 | export 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' | |||
3 | export * from './change-ownership-command.js' | 3 | export * from './change-ownership-command.js' |
4 | export * from './channels.js' | 4 | export * from './channels.js' |
5 | export * from './channels-command.js' | 5 | export * from './channels-command.js' |
6 | export * from './chapters-command.js' | ||
6 | export * from './channel-syncs-command.js' | 7 | export * from './channel-syncs-command.js' |
7 | export * from './comments-command.js' | 8 | export * from './comments-command.js' |
8 | export * from './history-command.js' | 9 | export * from './history-command.js' |
diff --git a/packages/tests/fixtures/video_chapters.mp4 b/packages/tests/fixtures/video_chapters.mp4 new file mode 100644 index 000000000..46cbaf624 --- /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' | |||
30 | import './video-captions.js' | 30 | import './video-captions.js' |
31 | import './video-channel-syncs.js' | 31 | import './video-channel-syncs.js' |
32 | import './video-channels.js' | 32 | import './video-channels.js' |
33 | import './video-chapters.js' | ||
33 | import './video-comments.js' | 34 | import './video-comments.js' |
34 | import './video-files.js' | 35 | import './video-files.js' |
35 | import './video-imports.js' | 36 | import './video-imports.js' |
diff --git a/packages/tests/src/api/check-params/video-captions.ts b/packages/tests/src/api/check-params/video-captions.ts index 4150b095f..ac4e85068 100644 --- a/packages/tests/src/api/check-params/video-captions.ts +++ b/packages/tests/src/api/check-params/video-captions.ts | |||
@@ -31,15 +31,7 @@ describe('Test video captions API validator', function () { | |||
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 | |||
3 | import { HttpStatusCode, Video, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' | ||
4 | import { | ||
5 | PeerTubeServer, | ||
6 | cleanupTests, | ||
7 | createSingleServer, | ||
8 | setAccessTokensToServers, | ||
9 | setDefaultVideoChannel | ||
10 | } from '@peertube/peertube-server-commands' | ||
11 | |||
12 | describe('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' | |||
4 | import './video-captions.js' | 4 | import './video-captions.js' |
5 | import './video-change-ownership.js' | 5 | import './video-change-ownership.js' |
6 | import './video-channels.js' | 6 | import './video-channels.js' |
7 | import './video-chapters.js' | ||
7 | import './channel-import-videos.js' | 8 | import './channel-import-videos.js' |
8 | import './video-channel-syncs.js' | 9 | import './video-channel-syncs.js' |
9 | import './video-comments.js' | 10 | import './video-comments.js' |
diff --git a/packages/tests/src/api/videos/video-chapters.ts b/packages/tests/src/api/videos/video-chapters.ts new file mode 100644 index 000000000..2f3dbcd2e --- /dev/null +++ b/packages/tests/src/api/videos/video-chapters.ts | |||
@@ -0,0 +1,342 @@ | |||
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
2 | |||
3 | import { VideoChapter, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models' | ||
4 | import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils' | ||
5 | import { | ||
6 | cleanupTests, | ||
7 | createMultipleServers, | ||
8 | doubleFollow, PeerTubeServer, setAccessTokensToServers, | ||
9 | setDefaultVideoChannel, | ||
10 | waitJobs | ||
11 | } from '@peertube/peertube-server-commands' | ||
12 | import { FIXTURE_URLS } from '@tests/shared/tests.js' | ||
13 | import { expect } from 'chai' | ||
14 | |||
15 | describe('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 @@ | |||
3 | import { expect } from 'chai' | 3 | import { expect } from 'chai' |
4 | import snakeCase from 'lodash-es/snakeCase.js' | 4 | import snakeCase from 'lodash-es/snakeCase.js' |
5 | import validator from 'validator' | 5 | import validator from 'validator' |
6 | import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate } from '@peertube/peertube-core-utils' | 6 | import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, parseChapters } from '@peertube/peertube-core-utils' |
7 | import { VideoResolution } from '@peertube/peertube-models' | 7 | import { VideoResolution } from '@peertube/peertube-models' |
8 | import { objectConverter, parseBytes, parseDurationToMs, parseSemVersion } from '@peertube/peertube-server/server/helpers/core-utils.js' | 8 | import { 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 | |||
203 | describe('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 | |||
43 | export type DeepOmitArray<T extends any[], K> = { | 43 | export 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 | |||
47 | export 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 @@ | |||
1 | import cors from 'cors' | 1 | import cors from 'cors' |
2 | import express from 'express' | 2 | import express from 'express' |
3 | import { VideoCommentObject, VideoPlaylistPrivacy, VideoPrivacy, VideoRateType } from '@peertube/peertube-models' | 3 | import { |
4 | VideoChapterObject, | ||
5 | VideoChaptersObject, | ||
6 | VideoCommentObject, | ||
7 | VideoPlaylistPrivacy, | ||
8 | VideoPrivacy, | ||
9 | VideoRateType | ||
10 | } from '@peertube/peertube-models' | ||
4 | import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js' | 11 | import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js' |
5 | import { getContextFilter } from '@server/lib/activitypub/context.js' | 12 | import { getContextFilter } from '@server/lib/activitypub/context.js' |
6 | import { getServerActor } from '@server/models/application/application.js' | 13 | import { getServerActor } from '@server/models/application/application.js' |
@@ -12,12 +19,18 @@ import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/act | |||
12 | import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js' | 19 | import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js' |
13 | import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js' | 20 | import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js' |
14 | import { | 21 | import { |
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' |
20 | import { cacheRoute } from '../../middlewares/cache/cache.js' | 28 | import { |
29 | apVideoChaptersSetCacheKey, | ||
30 | buildAPVideoChaptersGroupsCache, | ||
31 | cacheRoute, | ||
32 | cacheRouteFactory | ||
33 | } from '../../middlewares/cache/cache.js' | ||
21 | import { | 34 | import { |
22 | activityPubRateLimiter, | 35 | activityPubRateLimiter, |
23 | asyncMiddleware, | 36 | asyncMiddleware, |
@@ -42,6 +55,8 @@ import { VideoCommentModel } from '../../models/video/video-comment.js' | |||
42 | import { VideoPlaylistModel } from '../../models/video/video-playlist.js' | 55 | import { VideoPlaylistModel } from '../../models/video/video-playlist.js' |
43 | import { VideoShareModel } from '../../models/video/video-share.js' | 56 | import { VideoShareModel } from '../../models/video/video-share.js' |
44 | import { activityPubResponse } from './utils.js' | 57 | import { activityPubResponse } from './utils.js' |
58 | import { VideoChapterModel } from '@server/models/video/video-chapter.js' | ||
59 | import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js' | ||
45 | 60 | ||
46 | const activityPubClientRouter = express.Router() | 61 | const activityPubClientRouter = express.Router() |
47 | activityPubClientRouter.use(cors()) | 62 | activityPubClientRouter.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 | |||
165 | const { middleware: chaptersCacheRouteMiddleware, instance: chaptersApiCache } = cacheRouteFactory() | ||
166 | |||
167 | InternalEventEmitter.Instance.on('chapters-updated', ({ video }) => { | ||
168 | if (video.remote) return | ||
169 | |||
170 | chaptersApiCache.clearGroupSafe(buildAPVideoChaptersGroupsCache({ videoId: video.uuid })) | ||
171 | }) | ||
172 | |||
173 | activityPubClientRouter.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 | |||
148 | activityPubClientRouter.get( | 184 | activityPubClientRouter.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 | ||
429 | async 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 | |||
393 | async function videoRedundancyController (req: express.Request, res: express.Response) { | 454 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares/index.js' | ||
3 | import { updateVideoChaptersValidator, videosCustomGetValidator } from '../../../middlewares/validators/index.js' | ||
4 | import { VideoChapterModel } from '@server/models/video/video-chapter.js' | ||
5 | import { HttpStatusCode, VideoChapterUpdate } from '@peertube/peertube-models' | ||
6 | import { sequelizeTypescript } from '@server/initializers/database.js' | ||
7 | import { retryTransactionWrapper } from '@server/helpers/database-utils.js' | ||
8 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js' | ||
9 | import { replaceChapters } from '@server/lib/video-chapters.js' | ||
10 | |||
11 | const videoChaptersRouter = express.Router() | ||
12 | |||
13 | videoChaptersRouter.get('/:id/chapters', | ||
14 | asyncMiddleware(videosCustomGetValidator('only-video')), | ||
15 | asyncMiddleware(listVideoChapters) | ||
16 | ) | ||
17 | |||
18 | videoChaptersRouter.put('/:videoId/chapters', | ||
19 | authenticate, | ||
20 | asyncMiddleware(updateVideoChaptersValidator), | ||
21 | asyncRetryTransactionMiddleware(replaceVideoChapters) | ||
22 | ) | ||
23 | |||
24 | // --------------------------------------------------------------------------- | ||
25 | |||
26 | export { | ||
27 | videoChaptersRouter | ||
28 | } | ||
29 | |||
30 | // --------------------------------------------------------------------------- | ||
31 | |||
32 | async 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 | |||
38 | async 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' | |||
49 | import { updateRouter } from './update.js' | 49 | import { updateRouter } from './update.js' |
50 | import { uploadRouter } from './upload.js' | 50 | import { uploadRouter } from './upload.js' |
51 | import { viewRouter } from './view.js' | 51 | import { viewRouter } from './view.js' |
52 | import { videoChaptersRouter } from './chapters.js' | ||
52 | 53 | ||
53 | const auditLogger = auditLoggerFactory('videos') | 54 | const auditLogger = auditLoggerFactory('videos') |
54 | const videosRouter = express.Router() | 55 | const videosRouter = express.Router() |
@@ -73,6 +74,7 @@ videosRouter.use('/', tokenRouter) | |||
73 | videosRouter.use('/', videoPasswordRouter) | 74 | videosRouter.use('/', videoPasswordRouter) |
74 | videosRouter.use('/', storyboardRouter) | 75 | videosRouter.use('/', storyboardRouter) |
75 | videosRouter.use('/', videoSourceRouter) | 76 | videosRouter.use('/', videoSourceRouter) |
77 | videosRouter.use('/', videoChaptersRouter) | ||
76 | 78 | ||
77 | videosRouter.get('/categories', | 79 | videosRouter.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' | |||
22 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js' | 22 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js' |
23 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' | 23 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' |
24 | import { VideoModel } from '../../../models/video/video.js' | 24 | import { VideoModel } from '../../../models/video/video.js' |
25 | import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js' | ||
25 | 26 | ||
26 | const lTags = loggerTagsFactory('api', 'video') | 27 | const lTags = loggerTagsFactory('api', 'video') |
27 | const auditLogger = auditLoggerFactory('videos') | 28 | const 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' |
35 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' | 35 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js' |
36 | import { VideoModel } from '../../../models/video/video.js' | 36 | import { VideoModel } from '../../../models/video/video.js' |
37 | import { getChaptersFromContainer } from '@peertube/peertube-ffmpeg' | ||
38 | import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js' | ||
37 | 39 | ||
38 | const lTags = loggerTagsFactory('api', 'video') | 40 | const lTags = loggerTagsFactory('api', 'video') |
39 | const auditLogger = auditLoggerFactory('videos') | 41 | const 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 | ||
201 | async function getContextData (type: ContextType, contextFilter: ContextFilter) { | 210 | async function getContextData (type: ContextType, contextFilter: ContextFilter) { |
diff --git a/server/server/helpers/custom-validators/activitypub/video-chapters.ts b/server/server/helpers/custom-validators/activitypub/video-chapters.ts new file mode 100644 index 000000000..38009991b --- /dev/null +++ b/server/server/helpers/custom-validators/activitypub/video-chapters.ts | |||
@@ -0,0 +1,15 @@ | |||
1 | import { isArray } from '../misc.js' | ||
2 | import { isVideoChapterTitleValid, isVideoChapterTimecodeValid } from '../video-chapters.js' | ||
3 | import { isActivityPubUrlValid } from './misc.js' | ||
4 | import { VideoChaptersObject } from '@peertube/peertube-models' | ||
5 | |||
6 | export 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 @@ | |||
1 | import { isArray } from './misc.js' | ||
2 | import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models' | ||
3 | import { Unpacked } from '@peertube/peertube-typescript-utils' | ||
4 | import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js' | ||
5 | import validator from 'validator' | ||
6 | |||
7 | export 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 | |||
16 | export function isVideoChapterValid (value: Unpacked<VideoChapterUpdate['chapters']>) { | ||
17 | return isVideoChapterTimecodeValid(value.timecode) && isVideoChapterTitleValid(value.title) | ||
18 | } | ||
19 | |||
20 | export function isVideoChapterTitleValid (value: any) { | ||
21 | return validator.default.isLength(value + '', CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE) | ||
22 | } | ||
23 | |||
24 | export 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 @@ | |||
1 | import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js' | 1 | import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js' |
2 | import { peertubeTruncate } from '../core-utils.js' | 2 | import { peertubeTruncate } from '../core-utils.js' |
3 | import { isUrlValid } from '../custom-validators/activitypub/misc.js' | 3 | import { isUrlValid } from '../custom-validators/activitypub/misc.js' |
4 | import { isArray } from '../custom-validators/misc.js' | ||
4 | 5 | ||
5 | export type YoutubeDLInfo = { | 6 | export 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 | ||
21 | export class YoutubeDLInfoBuilder { | 27 | export 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' | |||
59 | import { VideoModel } from '../models/video/video.js' | 59 | import { VideoModel } from '../models/video/video.js' |
60 | import { VideoViewModel } from '../models/view/video-view.js' | 60 | import { VideoViewModel } from '../models/view/video-view.js' |
61 | import { CONFIG } from './config.js' | 61 | import { CONFIG } from './config.js' |
62 | import { VideoChapterModel } from '@server/models/video/video-chapter.js' | ||
62 | 63 | ||
63 | pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string | 64 | pg.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 | ||
83 | function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) { | ||
84 | return video.url + '/chapters' | ||
85 | } | ||
86 | |||
83 | function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) { | 87 | function 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 @@ | |||
1 | import { CreationAttributes, Transaction } from 'sequelize' | 1 | import { CreationAttributes, Transaction } from 'sequelize' |
2 | import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType_Type } from '@peertube/peertube-models' | 2 | import { |
3 | import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils.js' | 3 | ActivityTagObject, |
4 | ThumbnailType, | ||
5 | VideoChaptersObject, | ||
6 | VideoObject, | ||
7 | VideoStreamingPlaylistType_Type | ||
8 | } from '@peertube/peertube-models' | ||
9 | import { deleteAllModels, filterNonExistingModels, retryTransactionWrapper } from '@server/helpers/database-utils.js' | ||
4 | import { logger, LoggerTagsFn } from '@server/helpers/logger.js' | 10 | import { logger, LoggerTagsFn } from '@server/helpers/logger.js' |
5 | import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js' | 11 | import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js' |
6 | import { setVideoTags } from '@server/lib/video.js' | 12 | import { 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' |
31 | import { getTrackerUrls, setVideoTrackers } from './trackers.js' | 37 | import { getTrackerUrls, setVideoTrackers } from './trackers.js' |
38 | import { fetchAP } from '../../activity.js' | ||
39 | import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js' | ||
40 | import { sequelizeTypescript } from '@server/initializers/database.js' | ||
41 | import { replaceChapters } from '@server/lib/video-chapters.js' | ||
32 | 42 | ||
33 | export abstract class APVideoAbstractBuilder { | 43 | export 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 @@ | |||
1 | import { MChannel, MVideo } from '@server/types/models/index.js' | 1 | import { MChannel, MVideo, MVideoImmutable } from '@server/types/models/index.js' |
2 | import { EventEmitter } from 'events' | 2 | import { EventEmitter } from 'events' |
3 | 3 | ||
4 | export interface PeerTubeInternalEvents { | 4 | export 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 | ||
14 | declare interface InternalEventEmitter { | 16 | declare interface InternalEventEmitter { |
diff --git a/server/server/lib/job-queue/handlers/video-import.ts b/server/server/lib/job-queue/handlers/video-import.ts index 7d5435a3b..09d974e90 100644 --- a/server/server/lib/job-queue/handlers/video-import.ts +++ b/server/server/lib/job-queue/handlers/video-import.ts | |||
@@ -32,6 +32,7 @@ import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImpo | |||
32 | import { getLowercaseExtension } from '@peertube/peertube-node-utils' | 32 | import { getLowercaseExtension } from '@peertube/peertube-node-utils' |
33 | import { | 33 | import { |
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' | |||
49 | import { Notifier } from '../../notifier/index.js' | 50 | import { Notifier } from '../../notifier/index.js' |
50 | import { generateLocalVideoMiniature } from '../../thumbnail.js' | 51 | import { generateLocalVideoMiniature } from '../../thumbnail.js' |
51 | import { JobQueue } from '../job-queue.js' | 52 | import { JobQueue } from '../job-queue.js' |
53 | import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js' | ||
52 | 54 | ||
53 | async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> { | 55 | async 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 @@ | |||
1 | import { parseChapters, sortBy } from '@peertube/peertube-core-utils' | ||
2 | import { VideoChapter } from '@peertube/peertube-models' | ||
3 | import { logger, loggerTagsFactory } from '@server/helpers/logger.js' | ||
4 | import { VideoChapterModel } from '@server/models/video/video-chapter.js' | ||
5 | import { MVideoImmutable } from '@server/types/models/index.js' | ||
6 | import { Transaction } from 'sequelize' | ||
7 | import { InternalEventEmitter } from './internal-event-emitter.js' | ||
8 | |||
9 | const lTags = loggerTagsFactory('video', 'chapters') | ||
10 | |||
11 | export 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 | |||
25 | export 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 | |||
39 | export 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 | |||
74 | async 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 | |||
90 | function 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' |
40 | import { getLocalVideoActivityPubUrl } from './activitypub/url.js' | 40 | import { getLocalVideoActivityPubUrl } from './activitypub/url.js' |
41 | import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js' | 41 | import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js' |
42 | import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js' | ||
42 | 43 | ||
43 | class YoutubeDlImportError extends Error { | 44 | class 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 @@ | |||
1 | import express from 'express' | ||
1 | import { HttpStatusCode } from '@peertube/peertube-models' | 2 | import { HttpStatusCode } from '@peertube/peertube-models' |
2 | import { ApiCache, APICacheOptions } from './shared/index.js' | 3 | import { ApiCache, APICacheOptions } from './shared/index.js' |
3 | 4 | ||
@@ -8,13 +9,13 @@ const defaultOptions: APICacheOptions = { | |||
8 | ] | 9 | ] |
9 | } | 10 | } |
10 | 11 | ||
11 | function cacheRoute (duration: string) { | 12 | export 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 | ||
17 | function cacheRouteFactory (options: APICacheOptions) { | 18 | export 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 | ||
25 | function buildPodcastGroupsCache (options: { | 26 | export 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 | // --------------------------------------------------------------------------- | 32 | export function buildAPVideoChaptersGroupsCache (options: { |
33 | videoId: number | string | ||
34 | }) { | ||
35 | return 'ap-video-chapters-' + options.videoId | ||
36 | } | ||
32 | 37 | ||
33 | export { | 38 | // --------------------------------------------------------------------------- |
34 | cacheRoute, | ||
35 | cacheRouteFactory, | ||
36 | 39 | ||
37 | buildPodcastGroupsCache | 40 | export 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 | |||
50 | export 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' | |||
3 | import { HttpStatusCode } from '@peertube/peertube-models' | 3 | import { HttpStatusCode } from '@peertube/peertube-models' |
4 | import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js' | 4 | import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js' |
5 | import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js' | 5 | import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js' |
6 | import { buildPodcastGroupsCache } from '../cache/index.js' | ||
7 | import { | 6 | import { |
8 | areValidationErrors, | 7 | areValidationErrors, |
9 | checkCanSeeVideo, | 8 | checkCanSeeVideo, |
@@ -114,15 +113,6 @@ const videoFeedsPodcastValidator = [ | |||
114 | } | 113 | } |
115 | ] | 114 | ] |
116 | 115 | ||
117 | const 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 | ||
128 | const videoSubscriptionFeedsValidator = [ | 118 | const 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' | |||
2 | export * from './video-captions.js' | 2 | export * from './video-captions.js' |
3 | export * from './video-channel-sync.js' | 3 | export * from './video-channel-sync.js' |
4 | export * from './video-channels.js' | 4 | export * from './video-channels.js' |
5 | export * from './video-chapters.js' | ||
5 | export * from './video-comments.js' | 6 | export * from './video-comments.js' |
6 | export * from './video-files.js' | 7 | export * from './video-files.js' |
7 | export * from './video-imports.js' | 8 | export * from './video-imports.js' |
diff --git a/server/server/middlewares/validators/videos/video-chapters.ts b/server/server/middlewares/validators/videos/video-chapters.ts new file mode 100644 index 000000000..5097e6380 --- /dev/null +++ b/server/server/middlewares/validators/videos/video-chapters.ts | |||
@@ -0,0 +1,34 @@ | |||
1 | import express from 'express' | ||
2 | import { body } from 'express-validator' | ||
3 | import { HttpStatusCode, UserRight } from '@peertube/peertube-models' | ||
4 | import { | ||
5 | areValidationErrors, checkUserCanManageVideo, doesVideoExist, | ||
6 | isValidVideoIdParam | ||
7 | } from '../shared/index.js' | ||
8 | import { areVideoChaptersValid } from '@server/helpers/custom-validators/video-chapters.js' | ||
9 | |||
10 | export 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' |
14 | import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js' | 14 | import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js' |
15 | import { | 15 | import { |
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 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { MVideo, MVideoChapter } from '@server/types/models/index.js' | ||
3 | import { VideoChapter, VideoChapterObject } from '@peertube/peertube-models' | ||
4 | import { AttributesOnly } from '@peertube/peertube-typescript-utils' | ||
5 | import { VideoModel } from './video.js' | ||
6 | import { Transaction } from 'sequelize' | ||
7 | import { getSort } from '../shared/sort.js' | ||
8 | |||
9 | @Table({ | ||
10 | tableName: 'videoChapter', | ||
11 | indexes: [ | ||
12 | { | ||
13 | fields: [ 'videoId', 'timecode' ], | ||
14 | unique: true | ||
15 | } | ||
16 | ] | ||
17 | }) | ||
18 | export 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' |
17 | import { MChannelDefault } from '../video/video-channels.js' | 17 | import { MChannelDefault } from '../video/video-channel.js' |
18 | import { MAccountBlocklistId } from './account-blocklist.js' | 18 | import { MAccountBlocklistId } from './account-blocklist.js' |
19 | 19 | ||
20 | type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M> | 20 | type 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' |
14 | import { MChannelFormattable } from '../video/video-channels.js' | 14 | import { MChannelFormattable } from '../video/video-channel.js' |
15 | import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting.js' | 15 | import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting.js' |
16 | 16 | ||
17 | type Use<K extends keyof UserModel, M> = PickWith<UserModel, K, M> | 17 | type 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' | |||
10 | export * from './video-caption.js' | 10 | export * from './video-caption.js' |
11 | export * from './video-change-ownership.js' | 11 | export * from './video-change-ownership.js' |
12 | export * from './video-channel-sync.js' | 12 | export * from './video-channel-sync.js' |
13 | export * from './video-channels.js' | 13 | export * from './video-channel.js' |
14 | export * from './video-chapter.js' | ||
14 | export * from './video-comment.js' | 15 | export * from './video-comment.js' |
15 | export * from './video-file.js' | 16 | export * from './video-file.js' |
16 | export * from './video-import.js' | 17 | export * from './video-import.js' |
diff --git a/server/server/types/models/video/video-channel-sync.ts b/server/server/types/models/video/video-channel-sync.ts index 2b3a3930f..7e4f9373b 100644 --- a/server/server/types/models/video/video-channel-sync.ts +++ b/server/server/types/models/video/video-channel-sync.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js' | 1 | import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js' |
2 | import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils' | 2 | import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils' |
3 | import { MChannelAccountDefault, MChannelFormattable } from './video-channels.js' | 3 | import { MChannelAccountDefault, MChannelFormattable } from './video-channel.js' |
4 | 4 | ||
5 | type Use<K extends keyof VideoChannelSyncModel, M> = PickWith<VideoChannelSyncModel, K, M> | 5 | type 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 @@ | |||
1 | import { VideoChapterModel } from '@server/models/video/video-chapter.js' | ||
2 | |||
3 | export 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' | |||
3 | import { VideoPlaylistModel } from '../../../models/video/video-playlist.js' | 3 | import { VideoPlaylistModel } from '../../../models/video/video-playlist.js' |
4 | import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account/index.js' | 4 | import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account/index.js' |
5 | import { MThumbnail } from './thumbnail.js' | 5 | import { MThumbnail } from './thumbnail.js' |
6 | import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channels.js' | 6 | import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channel.js' |
7 | 7 | ||
8 | type Use<K extends keyof VideoPlaylistModel, M> = PickWith<VideoPlaylistModel, K, M> | 8 | type 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' |
20 | import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js' | 20 | import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js' |
21 | import { MVideoLive } from './video-live.js' | 21 | import { MVideoLive } from './video-live.js' |
22 | import { | 22 | import { |
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 8d85f9c77..e3931a36e 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml | |||
@@ -257,6 +257,8 @@ tags: | |||
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: |