Browse Source

Add video chapters support

Chocobozzz 9 months ago
parent
commit
77b70702d2
100 changed files with 1892 additions and 158 deletions
  1. 51 1
      client/src/app/+videos/+video-edit/shared/video-edit.component.html
  2. 26 0
      client/src/app/+videos/+video-edit/shared/video-edit.component.scss
  3. 85 13
      client/src/app/+videos/+video-edit/shared/video-edit.component.ts
  4. 5 2
      client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
  5. 18 16
      client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts
  6. 1 0
      client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html
  7. 14 5
      client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts
  8. 24 4
      client/src/app/+videos/+video-edit/video-add-components/video-send.ts
  9. 5 2
      client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
  10. 1 0
      client/src/app/+videos/+video-edit/video-update.component.html
  11. 27 6
      client/src/app/+videos/+video-edit/video-update.component.ts
  12. 10 3
      client/src/app/+videos/+video-edit/video-update.resolver.ts
  13. 14 2
      client/src/app/+videos/+video-watch/video-watch.component.ts
  14. 0 12
      client/src/app/helpers/utils/object.ts
  15. 2 2
      client/src/app/menu/language-chooser.component.ts
  16. 32 0
      client/src/app/shared/form-validators/video-chapter-validators.ts
  17. 0 8
      client/src/app/shared/form-validators/video-validators.ts
  18. 3 3
      client/src/app/shared/shared-forms/form-reactive.service.ts
  19. 54 5
      client/src/app/shared/shared-forms/form-validator.service.ts
  20. 2 4
      client/src/app/shared/shared-forms/input-text.component.ts
  21. 1 1
      client/src/app/shared/shared-forms/markdown-textarea.component.html
  22. 2 1
      client/src/app/shared/shared-forms/markdown-textarea.component.ts
  23. 1 0
      client/src/app/shared/shared-forms/timestamp-input.component.scss
  24. 1 1
      client/src/app/shared/shared-main/buttons/button.component.html
  25. 3 0
      client/src/app/shared/shared-main/shared-main.module.ts
  26. 2 2
      client/src/app/shared/shared-main/video-caption/video-caption.service.ts
  27. 2 0
      client/src/app/shared/shared-main/video/index.ts
  28. 34 0
      client/src/app/shared/shared-main/video/video-chapter.service.ts
  29. 43 0
      client/src/app/shared/shared-main/video/video-chapters-edit.model.ts
  30. 7 0
      client/src/assets/player/peertube-player.ts
  31. 64 0
      client/src/assets/player/shared/control-bar/chapters-plugin.ts
  32. 2 0
      client/src/assets/player/shared/control-bar/index.ts
  33. 24 0
      client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts
  34. 3 1
      client/src/assets/player/shared/control-bar/storyboard-plugin.ts
  35. 20 0
      client/src/assets/player/shared/control-bar/time-tooltip.ts
  36. 2 1
      client/src/assets/player/types/peertube-player-options.ts
  37. 14 1
      client/src/assets/player/types/peertube-videojs-typings.ts
  38. 19 0
      client/src/sass/player/control-bar.scss
  39. 7 2
      client/src/standalone/videos/embed.ts
  40. 15 3
      client/src/standalone/videos/shared/player-options-builder.ts
  41. 6 1
      client/src/standalone/videos/shared/video-fetcher.ts
  42. 13 9
      packages/core-utils/src/common/array.ts
  43. 3 1
      packages/core-utils/src/common/date.ts
  44. 1 0
      packages/core-utils/src/index.ts
  45. 32 0
      packages/core-utils/src/string/chapters.ts
  46. 1 0
      packages/core-utils/src/string/index.ts
  47. 18 1
      packages/ffmpeg/src/ffprobe.ts
  48. 2 1
      packages/models/src/activitypub/context.ts
  49. 1 0
      packages/models/src/activitypub/objects/index.ts
  50. 11 0
      packages/models/src/activitypub/objects/video-chapters-object.ts
  51. 1 0
      packages/models/src/activitypub/objects/video-object.ts
  52. 6 0
      packages/models/src/videos/chapter/chapter-update.model.ts
  53. 4 0
      packages/models/src/videos/chapter/chapter.model.ts
  54. 2 0
      packages/models/src/videos/chapter/index.ts
  55. 1 0
      packages/models/src/videos/index.ts
  56. 3 0
      packages/server-commands/src/server/server.ts
  57. 38 0
      packages/server-commands/src/videos/chapters-command.ts
  58. 1 0
      packages/server-commands/src/videos/index.ts
  59. BIN
      packages/tests/fixtures/video_chapters.mp4
  60. 1 0
      packages/tests/src/api/check-params/index.ts
  61. 14 9
      packages/tests/src/api/check-params/video-captions.ts
  62. 172 0
      packages/tests/src/api/check-params/video-chapters.ts
  63. 1 0
      packages/tests/src/api/videos/index.ts
  64. 342 0
      packages/tests/src/api/videos/video-chapters.ts
  65. 26 1
      packages/tests/src/server-helpers/core-utils.ts
  66. 3 0
      packages/tests/src/shared/tests.ts
  67. 2 0
      packages/typescript-utils/src/types.ts
  68. 63 2
      server/server/controllers/activitypub/client.ts
  69. 51 0
      server/server/controllers/api/videos/chapters.ts
  70. 2 0
      server/server/controllers/api/videos/index.ts
  71. 11 0
      server/server/controllers/api/videos/update.ts
  72. 9 0
      server/server/controllers/api/videos/upload.ts
  73. 10 1
      server/server/helpers/activity-pub-utils.ts
  74. 15 0
      server/server/helpers/custom-validators/activitypub/video-chapters.ts
  75. 26 0
      server/server/helpers/custom-validators/video-chapters.ts
  76. 10 1
      server/server/helpers/youtube-dl/youtube-dl-info-builder.ts
  77. 3 0
      server/server/initializers/constants.ts
  78. 2 0
      server/server/initializers/database.ts
  79. 5 0
      server/server/lib/activitypub/url.ts
  80. 33 3
      server/server/lib/activitypub/videos/shared/abstract-builder.ts
  81. 2 0
      server/server/lib/activitypub/videos/shared/creator.ts
  82. 2 0
      server/server/lib/activitypub/videos/updater.ts
  83. 3 1
      server/server/lib/internal-event-emitter.ts
  84. 6 0
      server/server/lib/job-queue/handlers/video-import.ts
  85. 99 0
      server/server/lib/video-chapters.ts
  86. 24 0
      server/server/lib/video-pre-import.ts
  87. 29 9
      server/server/middlewares/cache/cache.ts
  88. 0 11
      server/server/middlewares/validators/feeds.ts
  89. 1 0
      server/server/middlewares/validators/videos/index.ts
  90. 34 0
      server/server/middlewares/validators/videos/video-chapters.ts
  91. 2 0
      server/server/models/video/formatter/video-activity-pub-format.ts
  92. 95 0
      server/server/models/video/video-chapter.ts
  93. 1 1
      server/server/types/models/account/account.ts
  94. 1 1
      server/server/types/models/user/user.ts
  95. 2 1
      server/server/types/models/video/index.ts
  96. 1 1
      server/server/types/models/video/video-channel-sync.ts
  97. 0 0
      server/server/types/models/video/video-channel.ts
  98. 3 0
      server/server/types/models/video/video-chapter.ts
  99. 1 1
      server/server/types/models/video/video-playlist.ts
  100. 1 1
      server/server/types/models/video/video.ts

+ 51 - 1
client/src/app/+videos/+video-edit/shared/video-edit.component.html

@@ -230,6 +230,57 @@
       </ng-template>
     </ng-container>
 
+    <ng-container ngbNavItem *ngIf="!liveVideo">
+      <a ngbNavLink i18n>Chapters</a>
+
+      <ng-template ngbNavContent>
+        <div class="row mb-5">
+          <div class="chapters col-md-12 col-xl-6" formArrayName="chapters">
+            <ng-container *ngFor="let chapterControl of getChaptersFormArray().controls; let i = index">
+              <div class="chapter" [formGroupName]="i">
+                <!-- Row 1 -->
+                <div></div>
+
+                <label i18n [ngClass]="{ 'hide-chapter-label': i !== 0 }" [for]="'timecode[' + i + ']'">Timecode</label>
+
+                <label i18n [ngClass]="{ 'hide-chapter-label': i !== 0 }" [for]="'title[' + i + ']'">Chapter name</label>
+
+                <div></div>
+
+                <!-- Row 2 -->
+                <div class="position">{{ i + 1 }}</div>
+
+                <my-timestamp-input
+                  class="d-block" [disableBorder]="false" [inputName]="'timecode[' + i + ']'"
+                  [maxTimestamp]="videoToUpdate?.duration" formControlName="timecode"
+                ></my-timestamp-input>
+
+                <div>
+                  <input
+                    [ngClass]="{ 'input-error': formErrors.chapters[i].title }"
+                    type="text" [id]="'title[' + i + ']'" [name]="'title[' + i + ']'" formControlName="title"
+                  />
+
+                  <div [ngClass]="{ 'opacity-0': !formErrors.chapters[i].title }" class="form-error">
+                    <span class="opacity-0">t</span> <!-- Ensure we have reserve a correct height -->
+                    {{ formErrors.chapters[i].title }}
+                  </div>
+                </div>
+
+                <my-delete-button *ngIf="!isLastChapterControl(i)" (click)="deleteChapterControl(i)"></my-delete-button>
+              </div>
+            </ng-container>
+
+            <div *ngIf="getChapterArrayErrors()" class="form-error">
+              {{ getChapterArrayErrors() }}
+            </div>
+          </div>
+
+          <my-embed *ngIf="videoToUpdate" class="col-md-12 col-xl-6" [video]="videoToUpdate"></my-embed>
+        </div>
+      </ng-template>
+    </ng-container>
+
     <ng-container ngbNavItem *ngIf="liveVideo">
       <a ngbNavLink i18n>Live settings</a>
 
@@ -312,7 +363,6 @@
 
     </ng-container>
 
-
     <ng-container ngbNavItem>
       <a ngbNavLink i18n>Advanced settings</a>
 

+ 26 - 0
client/src/app/+videos/+video-edit/shared/video-edit.component.scss

@@ -117,6 +117,32 @@ p-calendar {
   @include orange-button;
 }
 
+.hide-chapter-label {
+  height: 0;
+  opacity: 0;
+}
+
+.chapter {
+  display: grid;
+  grid-template-columns: auto auto minmax(150px, 350px) 1fr;
+  grid-template-rows: auto auto;
+  column-gap: 1rem;
+
+  .position {
+    height: 31px;
+    display: flex;
+    align-items: center;
+  }
+
+  my-delete-button {
+    width: fit-content;
+  }
+
+  .form-error {
+    margin-top: 0;
+  }
+}
+
 @include on-small-main-col {
   .form-columns {
     grid-template-columns: 1fr;

+ 85 - 13
client/src/app/+videos/+video-edit/shared/video-edit.component.ts

@@ -2,10 +2,10 @@ import { forkJoin } from 'rxjs'
 import { map } from 'rxjs/operators'
 import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model'
 import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
-import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms'
+import { AbstractControl, FormArray, FormGroup, Validators } from '@angular/forms'
 import { HooksService, PluginService, ServerService } from '@app/core'
 import { removeElementFromArray } from '@app/helpers'
-import { BuildFormValidator } from '@app/shared/form-validators'
+import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators'
 import {
   VIDEO_CATEGORY_VALIDATOR,
   VIDEO_CHANNEL_VALIDATOR,
@@ -20,9 +20,10 @@ import {
   VIDEO_SUPPORT_VALIDATOR,
   VIDEO_TAGS_ARRAY_VALIDATOR
 } from '@app/shared/form-validators/video-validators'
-import { FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms'
+import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators'
+import { FormReactiveErrors, FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms'
 import { InstanceService } from '@app/shared/shared-instance'
-import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoChaptersEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import {
   HTMLServerConfig,
@@ -30,6 +31,7 @@ import {
   LiveVideoLatencyMode,
   RegisterClientFormFieldOptions,
   RegisterClientVideoFieldOptions,
+  VideoChapter,
   VideoConstant,
   VideoDetails,
   VideoPrivacy,
@@ -57,7 +59,7 @@ type PluginField = {
 })
 export class VideoEditComponent implements OnInit, OnDestroy {
   @Input() form: FormGroup
-  @Input() formErrors: { [ id: string ]: string } = {}
+  @Input() formErrors: FormReactiveErrors & { chapters?: { title: string }[] } = {}
   @Input() validationMessages: FormReactiveValidationMessages = {}
 
   @Input() videoToUpdate: VideoDetails
@@ -68,6 +70,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
   @Input() videoCaptions: VideoCaptionWithPathEdit[] = []
   @Input() videoSource: VideoSource
 
+  @Input() videoChapters: VideoChapter[] = []
+
   @Input() hideWaitTranscoding = false
   @Input() updateVideoFileEnabled = false
 
@@ -150,7 +154,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
       licence: this.serverConfig.defaults.publish.licence,
       tags: []
     }
-    const obj: { [ id: string ]: BuildFormValidator } = {
+    const obj: BuildFormArgument = {
       name: VIDEO_NAME_VALIDATOR,
       privacy: VIDEO_PRIVACY_VALIDATOR,
       videoPassword: VIDEO_PASSWORD_VALIDATOR,
@@ -183,12 +187,16 @@ export class VideoEditComponent implements OnInit, OnDestroy {
       defaultValues
     )
 
-    this.form.addControl('captions', new FormArray([
-      new FormGroup({
-        language: new FormControl(),
-        captionfile: new FormControl()
-      })
-    ]))
+    this.form.addControl('chapters', new FormArray([], VIDEO_CHAPTERS_ARRAY_VALIDATOR.VALIDATORS))
+    this.addNewChapterControl()
+
+    this.form.get('chapters').valueChanges.subscribe((chapters: { title: string, timecode: string }[]) => {
+      const lastChapter = chapters[chapters.length - 1]
+
+      if (lastChapter.title || lastChapter.timecode) {
+        this.addNewChapterControl()
+      }
+    })
 
     this.trackChannelChange()
     this.trackPrivacyChange()
@@ -426,6 +434,70 @@ export class VideoEditComponent implements OnInit, OnDestroy {
     this.form.valueChanges.subscribe(() => this.formValidatorService.updateTreeValidity(this.pluginDataFormGroup))
   }
 
+  // ---------------------------------------------------------------------------
+
+  addNewChapterControl () {
+    const chaptersFormArray = this.getChaptersFormArray()
+    const controls = chaptersFormArray.controls
+
+    if (controls.length !== 0) {
+      const lastControl = chaptersFormArray.controls[controls.length - 1]
+      lastControl.get('title').addValidators(Validators.required)
+    }
+
+    this.formValidatorService.addControlInFormArray({
+      controlName: 'chapters',
+      formArray: chaptersFormArray,
+      formErrors: this.formErrors,
+      validationMessages: this.validationMessages,
+      formToBuild: {
+        timecode: null,
+        title: VIDEO_CHAPTER_TITLE_VALIDATOR
+      },
+      defaultValues: {
+        timecode: 0
+      }
+    })
+  }
+
+  getChaptersFormArray () {
+    return this.form.controls['chapters'] as FormArray
+  }
+
+  deleteChapterControl (index: number) {
+    this.formValidatorService.removeControlFromFormArray({
+      controlName: 'chapters',
+      formArray: this.getChaptersFormArray(),
+      formErrors: this.formErrors,
+      validationMessages: this.validationMessages,
+      index
+    })
+  }
+
+  isLastChapterControl (index: number) {
+    return this.getChaptersFormArray().length - 1 === index
+  }
+
+  patchChapters (chaptersEdit: VideoChaptersEdit) {
+    const totalChapters = chaptersEdit.getChaptersForUpdate().length
+    const totalControls = this.getChaptersFormArray().length
+
+    // Add missing controls. We use <= because we need the "empty control" to add another chapter
+    for (let i = 0; i <= totalChapters - totalControls; i++) {
+      this.addNewChapterControl()
+    }
+
+    this.form.patchValue(chaptersEdit.toFormPatch())
+  }
+
+  getChapterArrayErrors () {
+    if (!this.getChaptersFormArray().errors) return ''
+
+    return Object.values(this.getChaptersFormArray().errors).join('. ')
+  }
+
+  // ---------------------------------------------------------------------------
+
   private trackPrivacyChange () {
     // We will update the schedule input and the wait transcoding checkbox validators
     this.form.controls['privacy']
@@ -469,8 +541,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
           } else {
             videoPasswordControl.clearValidators()
           }
-          videoPasswordControl.updateValueAndValidity()
 
+          videoPasswordControl.updateValueAndValidity()
         }
       )
   }

+ 5 - 2
client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts

@@ -4,7 +4,7 @@ import { Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
 import { scrollToTop } from '@app/helpers'
 import { FormReactiveService } from '@app/shared/shared-forms'
-import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { LiveVideoService } from '@app/shared/shared-video-live'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { logger } from '@root-helpers/logger'
@@ -54,6 +54,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
     protected serverService: ServerService,
     protected videoService: VideoService,
     protected videoCaptionService: VideoCaptionService,
+    protected videoChapterService: VideoChapterService,
     private liveVideoService: LiveVideoService,
     private router: Router,
     private hooks: HooksService
@@ -137,6 +138,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
     video.uuid = this.videoUUID
     video.shortUUID = this.videoShortUUID
 
+    this.chaptersEdit.patch(this.form.value)
+
     const saveReplay = this.form.value.saveReplay
     const replaySettings = saveReplay
       ? { privacy: this.form.value.replayPrivacy }
@@ -151,7 +154,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
 
     // Update the video
     forkJoin([
-      this.updateVideoAndCaptions(video),
+      this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions }),
 
       this.liveVideoService.updateLive(this.videoId, liveVideoUpdate)
     ]).subscribe({

+ 18 - 16
client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts

@@ -4,7 +4,7 @@ import { Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
 import { scrollToTop } from '@app/helpers'
 import { FormReactiveService } from '@app/shared/shared-forms'
-import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
+import { VideoCaptionService, VideoChapterService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { logger } from '@root-helpers/logger'
 import { PeerTubeProblemDocument, ServerErrorCode, VideoUpdate } from '@peertube/peertube-models'
@@ -42,6 +42,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af
     protected serverService: ServerService,
     protected videoService: VideoService,
     protected videoCaptionService: VideoCaptionService,
+    protected videoChapterService: VideoChapterService,
     private router: Router,
     private videoImportService: VideoImportService,
     private hooks: HooksService
@@ -124,24 +125,25 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af
     if (!await this.isFormValid()) return
 
     this.video.patch(this.form.value)
+    this.chaptersEdit.patch(this.form.value)
 
     this.isUpdatingVideo = true
 
     // Update the video
-    this.updateVideoAndCaptions(this.video)
-        .subscribe({
-          next: () => {
-            this.isUpdatingVideo = false
-            this.notifier.success($localize`Video to import updated.`)
-
-            this.router.navigate([ '/my-library', 'video-imports' ])
-          },
-
-          error: err => {
-            this.error = err.message
-            scrollToTop()
-            logger.error(err)
-          }
-        })
+    this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit })
+      .subscribe({
+        next: () => {
+          this.isUpdatingVideo = false
+          this.notifier.success($localize`Video to import updated.`)
+
+          this.router.navigate([ '/my-library', 'video-imports' ])
+        },
+
+        error: err => {
+          this.error = err.message
+          scrollToTop()
+          logger.error(err)
+        }
+      })
   }
 }

+ 1 - 0
client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html

@@ -56,6 +56,7 @@
 <!-- Hidden because we want to load the component -->
 <form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
   <my-video-edit
+    #videoEdit
     [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [forbidScheduledPublication]="true"
     [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
     type="import-url"

+ 14 - 5
client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts

@@ -1,16 +1,17 @@
 import { forkJoin } from 'rxjs'
 import { map, switchMap } from 'rxjs/operators'
-import { AfterViewInit, Component, EventEmitter, OnInit, Output } from '@angular/core'
+import { AfterViewInit, Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
 import { Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
 import { scrollToTop } from '@app/helpers'
 import { FormReactiveService } from '@app/shared/shared-forms'
-import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
+import { VideoCaptionService, VideoChapterService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { logger } from '@root-helpers/logger'
 import { VideoUpdate } from '@peertube/peertube-models'
 import { hydrateFormFromVideo } from '../shared/video-edit-utils'
 import { VideoSend } from './video-send'
+import { VideoEditComponent } from '../shared/video-edit.component'
 
 @Component({
   selector: 'my-video-import-url',
@@ -21,6 +22,8 @@ import { VideoSend } from './video-send'
   ]
 })
 export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterViewInit, CanComponentDeactivate {
+  @ViewChild('videoEdit', { static: false }) videoEditComponent: VideoEditComponent
+
   @Output() firstStepDone = new EventEmitter<string>()
   @Output() firstStepError = new EventEmitter<void>()
 
@@ -41,6 +44,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
     protected serverService: ServerService,
     protected videoService: VideoService,
     protected videoCaptionService: VideoCaptionService,
+    protected videoChapterService: VideoChapterService,
     private router: Router,
     private videoImportService: VideoImportService,
     private hooks: HooksService
@@ -85,12 +89,13 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
           switchMap(previous => {
             return forkJoin([
               this.videoCaptionService.listCaptions(previous.video.uuid),
+              this.videoChapterService.getChapters({ videoId: previous.video.uuid }),
               this.videoService.getVideo({ videoId: previous.video.uuid })
-            ]).pipe(map(([ videoCaptionsResult, video ]) => ({ videoCaptions: videoCaptionsResult.data, video })))
+            ]).pipe(map(([ videoCaptionsResult, { chapters }, video ]) => ({ videoCaptions: videoCaptionsResult.data, chapters, video })))
           })
         )
         .subscribe({
-          next: ({ video, videoCaptions }) => {
+          next: ({ video, videoCaptions, chapters }) => {
             this.loadingBar.useRef().complete()
             this.firstStepDone.emit(video.name)
             this.isImportingVideo = false
@@ -99,9 +104,12 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
             this.video = new VideoEdit(video)
             this.video.patch({ privacy: this.firstStepPrivacyId })
 
+            this.chaptersEdit.loadFromAPI(chapters)
+
             this.videoCaptions = videoCaptions
 
             hydrateFormFromVideo(this.form, this.video, true)
+            setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit))
           },
 
           error: err => {
@@ -117,11 +125,12 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
     if (!await this.isFormValid()) return
 
     this.video.patch(this.form.value)
+    this.chaptersEdit.patch(this.form.value)
 
     this.isUpdatingVideo = true
 
     // Update the video
-    this.updateVideoAndCaptions(this.video)
+    this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit })
         .subscribe({
           next: () => {
             this.isUpdatingVideo = false

+ 24 - 4
client/src/app/+videos/+video-edit/video-add-components/video-send.ts

@@ -4,9 +4,17 @@ import { Directive, EventEmitter, OnInit } from '@angular/core'
 import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core'
 import { listUserChannelsForSelect } from '@app/helpers'
 import { FormReactive } from '@app/shared/shared-forms'
-import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
+import {
+  VideoCaptionEdit,
+  VideoCaptionService,
+  VideoChapterService,
+  VideoChaptersEdit,
+  VideoEdit,
+  VideoService
+} from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models'
+import { of } from 'rxjs'
 
 @Directive()
 // eslint-disable-next-line @angular-eslint/directive-class-suffix
@@ -14,6 +22,7 @@ export abstract class VideoSend extends FormReactive implements OnInit {
   userVideoChannels: SelectChannelItem[] = []
   videoPrivacies: VideoConstant<VideoPrivacyType>[] = []
   videoCaptions: VideoCaptionEdit[] = []
+  chaptersEdit = new VideoChaptersEdit()
 
   firstStepPrivacyId: VideoPrivacyType
   firstStepChannelId: number
@@ -28,6 +37,7 @@ export abstract class VideoSend extends FormReactive implements OnInit {
   protected serverService: ServerService
   protected videoService: VideoService
   protected videoCaptionService: VideoCaptionService
+  protected videoChapterService: VideoChapterService
 
   protected serverConfig: HTMLServerConfig
 
@@ -60,13 +70,23 @@ export abstract class VideoSend extends FormReactive implements OnInit {
           })
   }
 
-  protected updateVideoAndCaptions (video: VideoEdit) {
+  protected updateVideoAndCaptionsAndChapters (options: {
+    video: VideoEdit
+    captions: VideoCaptionEdit[]
+    chapters?: VideoChaptersEdit
+  }) {
+    const { video, captions, chapters } = options
+
     this.loadingBar.useRef().start()
 
     return this.videoService.updateVideo(video)
         .pipe(
-          // Then update captions
-          switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions)),
+          switchMap(() => this.videoCaptionService.updateCaptions(video.uuid, captions)),
+          switchMap(() => {
+            return chapters
+              ? this.videoChapterService.updateChapters(video.uuid, chapters)
+              : of(true)
+          }),
           tap(() => this.loadingBar.useRef().complete()),
           catchError(err => {
             this.loadingBar.useRef().complete()

+ 5 - 2
client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts

@@ -7,7 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
 import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
 import { FormReactiveService } from '@app/shared/shared-forms'
-import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { logger } from '@root-helpers/logger'
 import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models'
@@ -63,6 +63,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     protected serverService: ServerService,
     protected videoService: VideoService,
     protected videoCaptionService: VideoCaptionService,
+    protected videoChapterService: VideoChapterService,
     private userService: UserService,
     private router: Router,
     private hooks: HooksService,
@@ -241,9 +242,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     video.uuid = this.videoUploadedIds.uuid
     video.shortUUID = this.videoUploadedIds.shortUUID
 
+    this.chaptersEdit.patch(this.form.value)
+
     this.isUpdatingVideo = true
 
-    this.updateVideoAndCaptions(video)
+    this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions, chapters: this.chaptersEdit })
         .subscribe({
           next: () => {
             this.isUpdatingVideo = false

+ 1 - 0
client/src/app/+videos/+video-edit/video-update.component.html

@@ -13,6 +13,7 @@
   <form novalidate [formGroup]="form">
 
     <my-video-edit
+      #videoEdit
       [form]="form" [formErrors]="formErrors" [forbidScheduledPublication]="forbidScheduledPublication"
       [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
       [videoCaptions]="videoCaptions" [hideWaitTranscoding]="isWaitTranscodingHidden()"

+ 27 - 6
client/src/app/+videos/+video-edit/video-update.component.ts

@@ -4,18 +4,28 @@ import { of, Subject, Subscription } from 'rxjs'
 import { catchError, map, switchMap } from 'rxjs/operators'
 import { SelectChannelItem } from 'src/types/select-options-item.model'
 import { HttpErrorResponse } from '@angular/common/http'
-import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'
+import { Component, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core'
 import { genericUploadErrorHandler } from '@app/helpers'
 import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
-import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
+import {
+  Video,
+  VideoCaptionEdit,
+  VideoCaptionService,
+  VideoChapterService,
+  VideoChaptersEdit,
+  VideoDetails,
+  VideoEdit,
+  VideoService
+} from '@app/shared/shared-main'
 import { LiveVideoService } from '@app/shared/shared-video-live'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { pick, simpleObjectsDeepEqual } from '@peertube/peertube-core-utils'
 import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoSource, VideoState } from '@peertube/peertube-models'
 import { hydrateFormFromVideo } from './shared/video-edit-utils'
 import { VideoUploadService } from './shared/video-upload.service'
+import { VideoEditComponent } from './shared/video-edit.component'
 
 const debugLogger = debug('peertube:video-update')
 
@@ -25,6 +35,8 @@ const debugLogger = debug('peertube:video-update')
   templateUrl: './video-update.component.html'
 })
 export class VideoUpdateComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate {
+  @ViewChild('videoEdit', { static: false }) videoEditComponent: VideoEditComponent
+
   videoEdit: VideoEdit
   videoDetails: VideoDetails
   videoSource: VideoSource
@@ -50,6 +62,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
   private uploadServiceSubscription: Subscription
   private updateSubcription: Subscription
 
+  private chaptersEdit = new VideoChaptersEdit()
+
   constructor (
     protected formReactiveService: FormReactiveService,
     private route: ActivatedRoute,
@@ -58,6 +72,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
     private videoService: VideoService,
     private loadingBar: LoadingBarService,
     private videoCaptionService: VideoCaptionService,
+    private videoChapterService: VideoChapterService,
     private server: ServerService,
     private liveVideoService: LiveVideoService,
     private videoUploadService: VideoUploadService,
@@ -84,10 +99,11 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
       .subscribe(state => this.onUploadVideoOngoing(state))
 
     const { videoData } = this.route.snapshot.data
-    const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData
+    const { video, videoChannels, videoCaptions, videoChapters, videoSource, liveVideo, videoPassword } = videoData
 
     this.videoDetails = video
     this.videoEdit = new VideoEdit(this.videoDetails, videoPassword)
+    this.chaptersEdit.loadFromAPI(videoChapters)
 
     this.userVideoChannels = videoChannels
     this.videoCaptions = videoCaptions
@@ -106,6 +122,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
   onFormBuilt () {
     hydrateFormFromVideo(this.form, this.videoEdit, true)
 
+    setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit))
+
     if (this.liveVideo) {
       this.form.patchValue({
         saveReplay: this.liveVideo.saveReplay,
@@ -172,6 +190,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
     if (!await this.checkAndConfirmVideoFileReplacement()) return
 
     this.videoEdit.patch(this.form.value)
+    this.chaptersEdit.patch(this.form.value)
 
     this.abortUpdateIfNeeded()
 
@@ -180,10 +199,12 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
 
     this.updateSubcription = this.videoReplacementUploadedSubject.pipe(
       switchMap(() => this.videoService.updateVideo(this.videoEdit)),
+      switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.uuid, this.videoCaptions)),
+      switchMap(() => {
+        if (this.liveVideo) return of(true)
 
-      // Then update captions
-      switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)),
-
+        return this.videoChapterService.updateChapters(this.videoEdit.uuid, this.chaptersEdit)
+      }),
       switchMap(() => {
         if (!this.liveVideo) return of(undefined)
 

+ 10 - 3
client/src/app/+videos/+video-edit/video-update.resolver.ts

@@ -4,7 +4,7 @@ import { Injectable } from '@angular/core'
 import { ActivatedRouteSnapshot } from '@angular/router'
 import { AuthService } from '@app/core'
 import { listUserChannelsForSelect } from '@app/helpers'
-import { VideoCaptionService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main'
+import { VideoCaptionService, VideoChapterService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main'
 import { LiveVideoService } from '@app/shared/shared-video-live'
 import { VideoPrivacy } from '@peertube/peertube-models'
 
@@ -15,6 +15,7 @@ export class VideoUpdateResolver {
     private liveVideoService: LiveVideoService,
     private authService: AuthService,
     private videoCaptionService: VideoCaptionService,
+    private videoChapterService: VideoChapterService,
     private videoPasswordService: VideoPasswordService
   ) {
   }
@@ -25,8 +26,8 @@ export class VideoUpdateResolver {
     return this.videoService.getVideo({ videoId: uuid })
                 .pipe(
                   switchMap(video => forkJoin(this.buildVideoObservables(video))),
-                  map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) =>
-                    ({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword }))
+                  map(([ video, videoSource, videoChannels, videoCaptions, videoChapters, liveVideo, videoPassword ]) =>
+                    ({ video, videoChannels, videoCaptions, videoChapters, videoSource, liveVideo, videoPassword }))
                 )
   }
 
@@ -46,6 +47,12 @@ export class VideoUpdateResolver {
           map(result => result.data)
         ),
 
+      this.videoChapterService
+        .getChapters({ videoId: video.uuid })
+        .pipe(
+          map(({ chapters }) => chapters)
+        ),
+
       video.isLive
         ? this.liveVideoService.getVideoLive(video.id)
         : of(undefined),

+ 14 - 2
client/src/app/+videos/+video-watch/video-watch.component.ts

@@ -18,7 +18,7 @@ import {
 } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
 import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers'
-import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main'
+import { Video, VideoCaptionService, VideoChapterService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main'
 import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
 import { LiveVideoService } from '@app/shared/shared-video-live'
 import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
@@ -31,6 +31,7 @@ import {
   ServerErrorCode,
   Storyboard,
   VideoCaption,
+  VideoChapter,
   VideoPrivacy,
   VideoState,
   VideoStateType
@@ -83,6 +84,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
 
   video: VideoDetails = null
   videoCaptions: VideoCaption[] = []
+  videoChapters: VideoChapter[] = []
   liveVideo: LiveVideo
   videoPassword: string
   storyboards: Storyboard[] = []
@@ -125,6 +127,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     private notifier: Notifier,
     private zone: NgZone,
     private videoCaptionService: VideoCaptionService,
+    private videoChapterService: VideoChapterService,
     private hotkeysService: HotkeysService,
     private hooks: HooksService,
     private pluginService: PluginService,
@@ -306,14 +309,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     forkJoin([
       videoAndLiveObs,
       this.videoCaptionService.listCaptions(videoId, videoPassword),
+      this.videoChapterService.getChapters({ videoId, videoPassword }),
       this.videoService.getStoryboards(videoId, videoPassword),
       this.userService.getAnonymousOrLoggedUser()
     ]).subscribe({
-      next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => {
+      next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, loggedInOrAnonymousUser ]) => {
         this.onVideoFetched({
           video,
           live,
           videoCaptions: captionsResult.data,
+          videoChapters: chaptersResult.chapters,
           storyboards,
           videoFileToken,
           videoPassword,
@@ -411,6 +416,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     video: VideoDetails
     live: LiveVideo
     videoCaptions: VideoCaption[]
+    videoChapters: VideoChapter[]
     storyboards: Storyboard[]
     videoFileToken: string
     videoPassword: string
@@ -422,6 +428,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
       video,
       live,
       videoCaptions,
+      videoChapters,
       storyboards,
       videoFileToken,
       videoPassword,
@@ -433,6 +440,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
 
     this.video = video
     this.videoCaptions = videoCaptions
+    this.videoChapters = videoChapters
     this.liveVideo = live
     this.videoFileToken = videoFileToken
     this.videoPassword = videoPassword
@@ -480,6 +488,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     const params = {
       video: this.video,
       videoCaptions: this.videoCaptions,
+      videoChapters: this.videoChapters,
       storyboards: this.storyboards,
       liveVideo: this.liveVideo,
       videoFileToken: this.videoFileToken,
@@ -636,6 +645,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     video: VideoDetails
     liveVideo: LiveVideo
     videoCaptions: VideoCaption[]
+    videoChapters: VideoChapter[]
     storyboards: Storyboard[]
 
     videoFileToken: string
@@ -651,6 +661,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
       video,
       liveVideo,
       videoCaptions,
+      videoChapters,
       storyboards,
       videoFileToken,
       videoPassword,
@@ -750,6 +761,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
       videoPassword: () => videoPassword,
 
       videoCaptions: playerCaptions,
+      videoChapters,
       storyboard,
 
       videoShortUUID: video.shortUUID,

+ 0 - 12
client/src/app/helpers/utils/object.ts

@@ -7,17 +7,6 @@ function removeElementFromArray <T> (arr: T[], elem: T) {
   if (index !== -1) arr.splice(index, 1)
 }
 
-function sortBy (obj: any[], key1: string, key2?: string) {
-  return obj.sort((a, b) => {
-    const elem1 = key2 ? a[key1][key2] : a[key1]
-    const elem2 = key2 ? b[key1][key2] : b[key1]
-
-    if (elem1 < elem2) return -1
-    if (elem1 === elem2) return 0
-    return 1
-  })
-}
-
 function splitIntoArray (value: any) {
   if (!value) return undefined
   if (Array.isArray(value)) return value
@@ -41,7 +30,6 @@ function toBoolean (value: any) {
 }
 
 export {
-  sortBy,
   immutableAssign,
   removeElementFromArray,
   splitIntoArray,

+ 2 - 2
client/src/app/menu/language-chooser.component.ts

@@ -1,7 +1,7 @@
 import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
-import { getDevLocale, isOnDevLocale, sortBy } from '@app/helpers'
+import { getDevLocale, isOnDevLocale } from '@app/helpers'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped } from '@peertube/peertube-core-utils'
+import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped, sortBy } from '@peertube/peertube-core-utils'
 
 @Component({
   selector: 'my-language-chooser',

+ 32 - 0
client/src/app/shared/form-validators/video-chapter-validators.ts

@@ -0,0 +1,32 @@
+import { AbstractControl, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'
+import { BuildFormValidator } from './form-validator.model'
+
+export const VIDEO_CHAPTER_TITLE_VALIDATOR: BuildFormValidator = {
+  VALIDATORS: [ Validators.minLength(2), Validators.maxLength(100) ], // Required is set dynamically
+  MESSAGES: {
+    required: $localize`A chapter title is required.`,
+    minlength: $localize`A chapter title should be more than 2 characters long.`,
+    maxlength: $localize`A chapter title should be less than 100 characters long.`
+  }
+}
+
+export const VIDEO_CHAPTERS_ARRAY_VALIDATOR: BuildFormValidator = {
+  VALIDATORS: [ uniqueTimecodeValidator() ],
+  MESSAGES: {}
+}
+
+function uniqueTimecodeValidator (): ValidatorFn {
+  return (control: AbstractControl): ValidationErrors => {
+    const array = control.value as { timecode: number, title: string }[]
+
+    for (const chapter of array) {
+      if (!chapter.title) continue
+
+      if (array.filter(c => c.title && c.timecode === chapter.timecode).length > 1) {
+        return { uniqueTimecode: $localize`Multiple chapters have the same timecode ${chapter.timecode}` }
+      }
+    }
+
+    return null
+  }
+}

+ 0 - 8
client/src/app/shared/form-validators/video-validators.ts

@@ -70,14 +70,6 @@ export const VIDEO_DESCRIPTION_VALIDATOR: BuildFormValidator = {
   }
 }
 
-export const VIDEO_TAG_VALIDATOR: BuildFormValidator = {
-  VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ],
-  MESSAGES: {
-    minlength: $localize`A tag should be more than 2 characters long.`,
-    maxlength: $localize`A tag should be less than 30 characters long.`
-  }
-}
-
 export const VIDEO_TAGS_ARRAY_VALIDATOR: BuildFormValidator = {
   VALIDATORS: [ Validators.maxLength(5), arrayTagLengthValidator() ],
   MESSAGES: {

+ 3 - 3
client/src/app/shared/shared-forms/form-reactive.service.ts

@@ -4,9 +4,9 @@ import { wait } from '@root-helpers/utils'
 import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
 import { FormValidatorService } from './form-validator.service'
 
-export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
+export type FormReactiveErrors = { [ id: string | number ]: string | FormReactiveErrors | FormReactiveErrors[] }
 export type FormReactiveValidationMessages = {
-  [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
+  [ id: string | number ]: { [ name: string ]: string } | FormReactiveValidationMessages | FormReactiveValidationMessages[]
 }
 
 @Injectable()
@@ -86,7 +86,7 @@ export class FormReactiveService {
 
       if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue
 
-      const staticMessages = validationMessages[field]
+      const staticMessages = validationMessages[field] as FormReactiveValidationMessages
       for (const key of Object.keys(control.errors)) {
         const formErrorValue = control.errors[key]
 

+ 54 - 5
client/src/app/shared/shared-forms/form-validator.service.ts

@@ -45,20 +45,20 @@ export class FormValidatorService {
     form: FormGroup,
     formErrors: FormReactiveErrors,
     validationMessages: FormReactiveValidationMessages,
-    obj: BuildFormArgument,
+    formToBuild: BuildFormArgument,
     defaultValues: BuildFormDefaultValues = {}
   ) {
-    for (const name of objectKeysTyped(obj)) {
+    for (const name of objectKeysTyped(formToBuild)) {
       formErrors[name] = ''
 
-      const field = obj[name]
+      const field = formToBuild[name]
       if (this.isRecursiveField(field)) {
         this.updateFormGroup(
           // FIXME: typings
           (form as any)[name],
           formErrors[name] as FormReactiveErrors,
           validationMessages[name] as FormReactiveValidationMessages,
-          obj[name] as BuildFormArgument,
+          formToBuild[name] as BuildFormArgument,
           defaultValues[name] as BuildFormDefaultValues
         )
         continue
@@ -66,7 +66,7 @@ export class FormValidatorService {
 
       if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
 
-      const defaultValue = defaultValues[name] || ''
+      const defaultValue = defaultValues[name] ?? ''
 
       form.addControl(
         name + '',
@@ -75,6 +75,55 @@ export class FormValidatorService {
     }
   }
 
+  addControlInFormArray (options: {
+    formErrors: FormReactiveErrors
+    validationMessages: FormReactiveValidationMessages
+    formArray: FormArray
+    controlName: string
+    formToBuild: BuildFormArgument
+    defaultValues?: BuildFormDefaultValues
+  }) {
+    const { formArray, formErrors, validationMessages, controlName, formToBuild, defaultValues = {} } = options
+
+    const formGroup = new FormGroup({})
+    if (!formErrors[controlName]) formErrors[controlName] = [] as FormReactiveErrors[]
+    if (!validationMessages[controlName]) validationMessages[controlName] = []
+
+    const formArrayErrors = formErrors[controlName] as FormReactiveErrors[]
+    const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[]
+
+    const totalControls = formArray.controls.length
+    formArrayErrors.push({})
+    formArrayValidationMessages.push({})
+
+    this.updateFormGroup(
+      formGroup,
+      formArrayErrors[totalControls],
+      formArrayValidationMessages[totalControls],
+      formToBuild,
+      defaultValues
+    )
+
+    formArray.push(formGroup)
+  }
+
+  removeControlFromFormArray (options: {
+    formErrors: FormReactiveErrors
+    validationMessages: FormReactiveValidationMessages
+    index: number
+    formArray: FormArray
+    controlName: string
+  }) {
+    const { formArray, formErrors, validationMessages, index, controlName } = options
+
+    const formArrayErrors = formErrors[controlName] as FormReactiveErrors[]
+    const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[]
+
+    formArrayErrors.splice(index, 1)
+    formArrayValidationMessages.splice(index, 1)
+    formArray.removeAt(index)
+  }
+
   updateTreeValidity (group: FormGroup | FormArray): void {
     for (const key of Object.keys(group.controls)) {
       // FIXME: typings

+ 2 - 4
client/src/app/shared/shared-forms/input-text.component.ts

@@ -1,6 +1,6 @@
 import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core'
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
-import { Notifier } from '@app/core'
+import { FormReactiveErrors } from './form-reactive.service'
 
 @Component({
   selector: 'my-input-text',
@@ -26,9 +26,7 @@ export class InputTextComponent implements ControlValueAccessor {
   @Input() withCopy = false
   @Input() readonly = false
   @Input() show = false
-  @Input() formError: string
-
-  constructor (private notifier: Notifier) { }
+  @Input() formError: string | FormReactiveErrors | FormReactiveErrors[]
 
   get inputType () {
     return this.show

+ 1 - 1
client/src/app/shared/shared-forms/markdown-textarea.component.html

@@ -25,7 +25,7 @@
       </ng-template>
     </ng-container>
 
-    <button (click)="onMaximizeClick()" class="maximize-button border-0 m-3" [disabled]="disabled">
+    <button type="button" (click)="onMaximizeClick()" class="maximize-button border-0 m-3" [disabled]="disabled">
       <my-global-icon *ngIf="!isMaximized" [ngbTooltip]="maximizeInText" iconName="fullscreen"></my-global-icon>
 
       <my-global-icon *ngIf="isMaximized" [ngbTooltip]="maximizeOutText" iconName="exit-fullscreen"></my-global-icon>

+ 2 - 1
client/src/app/shared/shared-forms/markdown-textarea.component.ts

@@ -6,6 +6,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
 import { SafeHtml } from '@angular/platform-browser'
 import { MarkdownService, ScreenService } from '@app/core'
 import { Video } from '@peertube/peertube-models'
+import { FormReactiveErrors } from './form-reactive.service'
 
 @Component({
   selector: 'my-markdown-textarea',
@@ -23,7 +24,7 @@ import { Video } from '@peertube/peertube-models'
 export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
   @Input() content = ''
 
-  @Input() formError: string
+  @Input() formError: string | FormReactiveErrors | FormReactiveErrors[]
 
   @Input() truncateTo3Lines: boolean
 

+ 1 - 0
client/src/app/shared/shared-forms/timestamp-input.component.scss

@@ -4,6 +4,7 @@
 p-inputmask {
   ::ng-deep input {
     width: 80px;
+    text-align: center;
 
     &:focus-within,
     &:focus {

+ 1 - 1
client/src/app/shared/shared-main/buttons/button.component.html

@@ -1,4 +1,4 @@
-<button *ngIf="!ptRouterLink" class="action-button" [ngClass]="classes" [ngbTooltip]="title">
+<button *ngIf="!ptRouterLink" type="button" class="action-button" [ngClass]="classes" [ngbTooltip]="title">
   <ng-container *ngTemplateOutlet="content"></ng-container>
 </button>
 

+ 3 - 0
client/src/app/shared/shared-main/shared-main.module.ts

@@ -49,6 +49,7 @@ import { UserHistoryService, UserNotificationsComponent, UserNotificationService
 import {
   EmbedComponent,
   RedundancyService,
+  VideoChapterService,
   VideoFileTokenService,
   VideoImportService,
   VideoOwnershipService,
@@ -215,6 +216,8 @@ import { VideoChannelService } from './video-channel'
 
     VideoPasswordService,
 
+    VideoChapterService,
+
     CustomPageService,
 
     ActorRedirectGuard

+ 2 - 2
client/src/app/shared/shared-main/video-caption/video-caption.service.ts

@@ -3,9 +3,9 @@ import { catchError, map, switchMap } from 'rxjs/operators'
 import { HttpClient } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { RestExtractor, ServerService } from '@app/core'
-import { objectToFormData, sortBy } from '@app/helpers'
+import { objectToFormData } from '@app/helpers'
 import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video'
-import { peertubeTranslate } from '@peertube/peertube-core-utils'
+import { peertubeTranslate, sortBy } from '@peertube/peertube-core-utils'
 import { ResultList, VideoCaption } from '@peertube/peertube-models'
 import { environment } from '../../../../environments/environment'
 import { VideoCaptionEdit } from './video-caption-edit.model'

+ 2 - 0
client/src/app/shared/shared-main/video/index.ts

@@ -1,5 +1,7 @@
 export * from './embed.component'
 export * from './redundancy.service'
+export * from './video-chapter.service'
+export * from './video-chapters-edit.model'
 export * from './video-details.model'
 export * from './video-edit.model'
 export * from './video-file-token.service'

+ 34 - 0
client/src/app/shared/shared-main/video/video-chapter.service.ts

@@ -0,0 +1,34 @@
+import { catchError } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor } from '@app/core'
+import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models'
+import { VideoPasswordService } from './video-password.service'
+import { VideoService } from './video.service'
+import { VideoChaptersEdit } from './video-chapters-edit.model'
+import { of } from 'rxjs'
+
+@Injectable()
+export class VideoChapterService {
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor
+  ) {}
+
+  getChapters (options: { videoId: string, videoPassword?: string }) {
+    const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword)
+
+    return this.authHttp.get<{ chapters: VideoChapter[] }>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}/chapters`, { headers })
+      .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  updateChapters (videoId: string, chaptersEdit: VideoChaptersEdit) {
+    if (chaptersEdit.shouldUpdateAPI() !== true) return of(true)
+
+    const body = { chapters: chaptersEdit.getChaptersForUpdate() } as VideoChapterUpdate
+
+    return this.authHttp.put(`${VideoService.BASE_VIDEO_URL}/${videoId}/chapters`, body)
+      .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+}

+ 43 - 0
client/src/app/shared/shared-main/video/video-chapters-edit.model.ts

@@ -0,0 +1,43 @@
+import { simpleObjectsDeepEqual, sortBy } from '@peertube/peertube-core-utils'
+import { VideoChapter } from '@peertube/peertube-models'
+
+export class VideoChaptersEdit {
+  private chaptersFromAPI: VideoChapter[] = []
+
+  private chapters: VideoChapter[]
+
+  loadFromAPI (chapters: VideoChapter[]) {
+    this.chapters = chapters || []
+
+    this.chaptersFromAPI = chapters
+  }
+
+  patch (values: { [ id: string ]: any }) {
+    const chapters = values.chapters || []
+
+    this.chapters = chapters.map((c: any) => {
+      return {
+        timecode: c.timecode || 0,
+        title: c.title
+      }
+    })
+  }
+
+  toFormPatch () {
+    return { chapters: this.chapters }
+  }
+
+  getChaptersForUpdate (): VideoChapter[] {
+    return this.chapters.filter(c => !!c.title)
+  }
+
+  hasDuplicateValues () {
+    const timecodes = this.chapters.map(c => c.timecode)
+
+    return new Set(timecodes).size !== this.chapters.length
+  }
+
+  shouldUpdateAPI () {
+    return simpleObjectsDeepEqual(sortBy(this.getChaptersForUpdate(), 'timecode'), this.chaptersFromAPI) !== true
+  }
+}

+ 7 - 0
client/src/assets/player/peertube-player.ts

@@ -7,6 +7,8 @@ import './shared/bezels/bezels-plugin'
 import './shared/peertube/peertube-plugin'
 import './shared/resolutions/peertube-resolutions-plugin'
 import './shared/control-bar/storyboard-plugin'
+import './shared/control-bar/chapters-plugin'
+import './shared/control-bar/time-tooltip'
 import './shared/control-bar/next-previous-video-button'
 import './shared/control-bar/p2p-info-button'
 import './shared/control-bar/peertube-link-button'
@@ -227,6 +229,7 @@ export class PeerTubePlayer {
     if (this.player.usingPlugin('upnext')) this.player.upnext().dispose()
     if (this.player.usingPlugin('stats')) this.player.stats().dispose()
     if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose()
+    if (this.player.usingPlugin('chapters')) this.player.chapters().dispose()
 
     if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose()
 
@@ -273,6 +276,10 @@ export class PeerTubePlayer {
       this.player.storyboard(this.currentLoadOptions.storyboard)
     }
 
+    if (this.currentLoadOptions.videoChapters) {
+      this.player.chapters({ chapters: this.currentLoadOptions.videoChapters })
+    }
+
     if (this.currentLoadOptions.dock) {
       this.player.peertubeDock(this.currentLoadOptions.dock)
     }

+ 64 - 0
client/src/assets/player/shared/control-bar/chapters-plugin.ts

@@ -0,0 +1,64 @@
+import videojs from 'video.js'
+import { ChaptersOptions } from '../../types'
+import { VideoChapter } from '@peertube/peertube-models'
+import { ProgressBarMarkerComponent } from './progress-bar-marker-component'
+
+const Plugin = videojs.getPlugin('plugin')
+
+class ChaptersPlugin extends Plugin {
+  private chapters: VideoChapter[] = []
+  private markers: ProgressBarMarkerComponent[] = []
+
+  constructor (player: videojs.Player, options: videojs.ComponentOptions & ChaptersOptions) {
+    super(player, options)
+
+    this.chapters = options.chapters
+
+    this.player.ready(() => {
+      player.addClass('vjs-chapters')
+
+      this.player.one('durationchange', () => {
+        for (const chapter of this.chapters) {
+          if (chapter.timecode === 0) continue
+
+          const marker = new ProgressBarMarkerComponent(player, { timecode: chapter.timecode })
+
+          this.markers.push(marker)
+          this.getSeekBar().addChild(marker)
+        }
+      })
+    })
+  }
+
+  dispose () {
+    for (const marker of this.markers) {
+      this.getSeekBar().removeChild(marker)
+    }
+  }
+
+  getChapter (timecode: number) {
+    if (this.chapters.length !== 0) {
+      for (let i = this.chapters.length - 1; i >= 0; i--) {
+        const chapter = this.chapters[i]
+
+        if (chapter.timecode <= timecode) {
+          this.player.addClass('has-chapter')
+
+          return chapter.title
+        }
+      }
+    }
+
+    this.player.removeClass('has-chapter')
+
+    return ''
+  }
+
+  private getSeekBar () {
+    return this.player.getDescendant('ControlBar', 'ProgressControl', 'SeekBar')
+  }
+}
+
+videojs.registerPlugin('chapters', ChaptersPlugin)
+
+export { ChaptersPlugin }

+ 2 - 0
client/src/assets/player/shared/control-bar/index.ts

@@ -1,6 +1,8 @@
+export * from './chapters-plugin'
 export * from './next-previous-video-button'
 export * from './p2p-info-button'
 export * from './peertube-link-button'
 export * from './peertube-live-display'
 export * from './storyboard-plugin'
 export * from './theater-button'
+export * from './time-tooltip'

+ 24 - 0
client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts

@@ -0,0 +1,24 @@
+import videojs from 'video.js'
+import { ProgressBarMarkerComponentOptions } from '../../types'
+
+const Component = videojs.getComponent('Component')
+
+export class ProgressBarMarkerComponent extends Component {
+  options_: ProgressBarMarkerComponentOptions & videojs.ComponentOptions
+
+  // eslint-disable-next-line @typescript-eslint/no-useless-constructor
+  constructor (player: videojs.Player, options?: ProgressBarMarkerComponentOptions & videojs.ComponentOptions) {
+    super(player, options)
+  }
+
+  createEl () {
+    const left = (this.options_.timecode / this.player().duration()) * 100
+
+    return videojs.dom.createEl('span', {
+      className: 'vjs-marker',
+      style: `left: ${left}%`
+    }) as HTMLButtonElement
+  }
+}
+
+videojs.registerComponent('ProgressBarMarkerComponent', ProgressBarMarkerComponent)

+ 3 - 1
client/src/assets/player/shared/control-bar/storyboard-plugin.ts

@@ -141,7 +141,9 @@ class StoryboardPlugin extends Plugin {
       const ctop = Math.floor(position / columns) * -scaledHeight
 
       const bgSize = `${imgWidth * scaleFactor}px ${imgHeight * scaleFactor}px`
-      const topOffset = -scaledHeight - 60
+
+      const timeTooltip = this.player.el().querySelector('.vjs-time-tooltip')
+      const topOffset = -scaledHeight + parseInt(getComputedStyle(timeTooltip).top.replace('px', '')) - 20
 
       const previewHalfSize = Math.round(scaledWidth / 2)
       let left = seekBarRect.width * seekBarX - previewHalfSize

+ 20 - 0
client/src/assets/player/shared/control-bar/time-tooltip.ts

@@ -0,0 +1,20 @@
+import { timeToInt } from '@peertube/peertube-core-utils'
+import videojs, { VideoJsPlayer } from 'video.js'
+
+const TimeToolTip = videojs.getComponent('TimeTooltip') as any // FIXME: typings don't have write method
+
+class TimeTooltip extends TimeToolTip {
+
+  write (timecode: string) {
+    const player: VideoJsPlayer = this.player()
+
+    if (player.usingPlugin('chapters')) {
+      const chapterTitle = player.chapters().getChapter(timeToInt(timecode))
+      if (chapterTitle) return super.write(chapterTitle + '\r\n' + timecode)
+    }
+
+    return super.write(timecode)
+  }
+}
+
+videojs.registerComponent('TimeTooltip', TimeTooltip)

+ 2 - 1
client/src/assets/player/types/peertube-player-options.ts

@@ -1,4 +1,4 @@
-import { LiveVideoLatencyModeType, VideoFile } from '@peertube/peertube-models'
+import { LiveVideoLatencyModeType, VideoChapter, VideoFile } from '@peertube/peertube-models'
 import { PluginsManager } from '@root-helpers/plugins-manager'
 import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
 import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings'
@@ -68,6 +68,7 @@ export type PeerTubePlayerLoadOptions = {
   }
 
   videoCaptions: VideoJSCaption[]
+  videoChapters: VideoChapter[]
   storyboard: VideoJSStoryboard
 
   videoUUID: string

+ 14 - 1
client/src/assets/player/types/peertube-videojs-typings.ts

@@ -1,7 +1,7 @@
 import { HlsConfig, Level } from 'hls.js'
 import videojs from 'video.js'
 import { Engine } from '@peertube/p2p-media-loader-hlsjs'
-import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models'
+import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models'
 import { BezelsPlugin } from '../shared/bezels/bezels-plugin'
 import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin'
 import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
@@ -19,6 +19,7 @@ import { UpNextPlugin } from '../shared/upnext/upnext-plugin'
 import { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
 import { PlayerMode } from './peertube-player-options'
 import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
+import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin'
 
 declare module 'video.js' {
 
@@ -62,6 +63,8 @@ declare module 'video.js' {
 
     peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin
 
+    chapters (options?: ChaptersOptions): ChaptersPlugin
+
     upnext (options?: UpNextPluginOptions): UpNextPlugin
 
     playlist (options?: PlaylistPluginOptions): PlaylistPlugin
@@ -142,6 +145,10 @@ type StoryboardOptions = {
   interval: number
 }
 
+type ChaptersOptions = {
+  chapters: VideoChapter[]
+}
+
 type PlaylistPluginOptions = {
   elements: VideoPlaylistElement[]
 
@@ -161,6 +168,10 @@ type UpNextPluginOptions = {
   isSuspended: () => boolean
 }
 
+type ProgressBarMarkerComponentOptions = {
+  timecode: number
+}
+
 type NextPreviousVideoButtonOptions = {
   type: 'next' | 'previous'
   handler?: () => void
@@ -273,6 +284,7 @@ export {
   NextPreviousVideoButtonOptions,
   ResolutionUpdateData,
   AutoResolutionUpdateData,
+  ProgressBarMarkerComponentOptions,
   PlaylistPluginOptions,
   MetricsPluginOptions,
   VideoJSCaption,
@@ -284,5 +296,6 @@ export {
   UpNextPluginOptions,
   LoadedQualityData,
   StoryboardOptions,
+  ChaptersOptions,
   PeerTubeLinkButtonOptions
 }

+ 19 - 0
client/src/sass/player/control-bar.scss

@@ -3,6 +3,16 @@
 @use '_mixins' as *;
 @use './_player-variables' as *;
 
+.vjs-peertube-skin.has-chapter {
+  .vjs-time-tooltip {
+    white-space: pre;
+    line-height: 1.5;
+    padding-top: 4px;
+    padding-bottom: 4px;
+    top: -4.9em;
+  }
+}
+
 .video-js.vjs-peertube-skin .vjs-control-bar {
   z-index: 100;
 
@@ -495,3 +505,12 @@
     }
   }
 }
+
+.vjs-marker {
+  position: absolute;
+  width: 3px;
+  opacity: .5;
+  background-color: #000;
+  height: 100%;
+  top: 0;
+}

+ 7 - 2
client/src/standalone/videos/embed.ts

@@ -195,10 +195,11 @@ export class PeerTubeEmbed {
       const {
         videoResponse,
         captionsPromise,
+        chaptersPromise,
         storyboardsPromise
       } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword })
 
-      return this.buildVideoPlayer({ videoResponse, captionsPromise, storyboardsPromise, forceAutoplay })
+      return this.buildVideoPlayer({ videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay })
     } catch (err) {
 
       if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
@@ -210,9 +211,10 @@ export class PeerTubeEmbed {
     videoResponse: Response
     storyboardsPromise: Promise<Response>
     captionsPromise: Promise<Response>
+    chaptersPromise: Promise<Response>
     forceAutoplay: boolean
   }) {
-    const { videoResponse, captionsPromise, storyboardsPromise, forceAutoplay } = options
+    const { videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay } = options
 
     const videoInfoPromise = videoResponse.json()
       .then(async (videoInfo: VideoDetails) => {
@@ -233,11 +235,13 @@ export class PeerTubeEmbed {
       { video, live, videoFileToken },
       translations,
       captionsResponse,
+      chaptersResponse,
       storyboardsResponse
     ] = await Promise.all([
       videoInfoPromise,
       this.translationsPromise,
       captionsPromise,
+      chaptersPromise,
       storyboardsPromise,
       this.buildPlayerIfNeeded()
     ])
@@ -260,6 +264,7 @@ export class PeerTubeEmbed {
     const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({
       video,
       captionsResponse,
+      chaptersResponse,
       translations,
 
       storyboardsResponse,

+ 15 - 3
client/src/standalone/videos/shared/player-options-builder.ts

@@ -5,6 +5,7 @@ import {
   Storyboard,
   Video,
   VideoCaption,
+  VideoChapter,
   VideoDetails,
   VideoPlaylistElement,
   VideoState,
@@ -199,6 +200,8 @@ export class PlayerOptionsBuilder {
 
     storyboardsResponse: Response
 
+    chaptersResponse: Response
+
     live?: LiveVideo
 
     alreadyPlayed: boolean
@@ -229,12 +232,14 @@ export class PlayerOptionsBuilder {
       forceAutoplay,
       playlist,
       live,
-      storyboardsResponse
+      storyboardsResponse,
+      chaptersResponse
     } = options
 
-    const [ videoCaptions, storyboard ] = await Promise.all([
+    const [ videoCaptions, storyboard, chapters ] = await Promise.all([
       this.buildCaptions(captionsResponse, translations),
-      this.buildStoryboard(storyboardsResponse)
+      this.buildStoryboard(storyboardsResponse),
+      this.buildChapters(chaptersResponse)
     ])
 
     return {
@@ -248,6 +253,7 @@ export class PlayerOptionsBuilder {
       subtitle: this.subtitle,
 
       storyboard,
+      videoChapters: chapters,
 
       startTime: playlist
         ? playlist.playlistTracker.getCurrentElement().startTimestamp
@@ -312,6 +318,12 @@ export class PlayerOptionsBuilder {
     }
   }
 
+  private async buildChapters (chaptersResponse: Response) {
+    const { chapters } = await chaptersResponse.json() as { chapters: VideoChapter[] }
+
+    return chapters
+  }
+
   private buildPlaylistOptions (options?: {
     playlistTracker: PlaylistTracker
     playNext: () => any

+ 6 - 1
client/src/standalone/videos/shared/video-fetcher.ts

@@ -36,9 +36,10 @@ export class VideoFetcher {
     }
 
     const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword })
+    const chaptersPromise = this.loadVideoChapters({ videoId, videoPassword })
     const storyboardsPromise = this.loadStoryboards(videoId)
 
-    return { captionsPromise, storyboardsPromise, videoResponse }
+    return { captionsPromise, chaptersPromise, storyboardsPromise, videoResponse }
   }
 
   loadLive (video: VideoDetails) {
@@ -64,6 +65,10 @@ export class VideoFetcher {
     return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword)
   }
 
+  private loadVideoChapters ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
+    return this.http.fetch(this.getVideoUrl(videoId) + '/chapters', { optionalAuth: true }, videoPassword)
+  }
+
   private getVideoUrl (id: string) {
     return window.location.origin + '/api/v1/videos/' + id
   }

+ 13 - 9
packages/core-utils/src/common/array.ts

@@ -1,4 +1,4 @@
-function findCommonElement <T> (array1: T[], array2: T[]) {
+export function findCommonElement <T> (array1: T[], array2: T[]) {
   for (const a of array1) {
     for (const b of array2) {
       if (a === b) return a
@@ -9,19 +9,19 @@ function findCommonElement <T> (array1: T[], array2: T[]) {
 }
 
 // Avoid conflict with other toArray() functions
-function arrayify <T> (element: T | T[]) {
+export function arrayify <T> (element: T | T[]) {
   if (Array.isArray(element)) return element
 
   return [ element ]
 }
 
 // Avoid conflict with other uniq() functions
-function uniqify <T> (elements: T[]) {
+export function uniqify <T> (elements: T[]) {
   return Array.from(new Set(elements))
 }
 
 // Thanks: https://stackoverflow.com/a/12646864
-function shuffle <T> (elements: T[]) {
+export function shuffle <T> (elements: T[]) {
   const shuffled = [ ...elements ]
 
   for (let i = shuffled.length - 1; i > 0; i--) {
@@ -33,9 +33,13 @@ function shuffle <T> (elements: T[]) {
   return shuffled
 }
 
-export {
-  uniqify,
-  findCommonElement,
-  shuffle,
-  arrayify
+export function sortBy (obj: any[], key1: string, key2?: string) {
+  return obj.sort((a, b) => {
+    const elem1 = key2 ? a[key1][key2] : a[key1]
+    const elem2 = key2 ? b[key1][key2] : b[key1]
+
+    if (elem1 < elem2) return -1
+    if (elem1 === elem2) return 0
+    return 1
+  })
 }

+ 3 - 1
packages/core-utils/src/common/date.ts

@@ -45,11 +45,13 @@ function isLastWeek (d: Date) {
 
 // ---------------------------------------------------------------------------
 
+export const timecodeRegexString = `((\\d+)[h:])?((\\d+)[m:])?((\\d+)s?)?`
+
 function timeToInt (time: number | string) {
   if (!time) return 0
   if (typeof time === 'number') return time
 
-  const reg = /^((\d+)[h:])?((\d+)[m:])?((\d+)s?)?$/
+  const reg = new RegExp(`^${timecodeRegexString}$`)
   const matches = time.match(reg)
 
   if (!matches) return 0

+ 1 - 0
packages/core-utils/src/index.ts

@@ -5,3 +5,4 @@ export * from './plugins/index.js'
 export * from './renderer/index.js'
 export * from './users/index.js'
 export * from './videos/index.js'
+export * from './string/index.js'

+ 32 - 0
packages/core-utils/src/string/chapters.ts

@@ -0,0 +1,32 @@
+import { timeToInt, timecodeRegexString } from '../common/date.js'
+
+const timecodeRegex = new RegExp(`^(${timecodeRegexString})\\s`)
+
+export function parseChapters (text: string) {
+  if (!text) return []
+
+  const lines = text.split(/\r?\n|\r|\n/g)
+  let foundChapters = false
+
+  const chapters: { timecode: number, title: string }[] = []
+
+  for (const line of lines) {
+    const matched = line.match(timecodeRegex)
+    if (!matched) {
+      // Stop chapters parsing
+      if (foundChapters) break
+
+      continue
+    }
+
+    foundChapters = true
+
+    const timecodeText = matched[1]
+    const timecode = timeToInt(timecodeText)
+    const title = line.replace(matched[0], '')
+
+    chapters.push({ timecode, title })
+  }
+
+  return chapters
+}

+ 1 - 0
packages/core-utils/src/string/index.ts

@@ -0,0 +1 @@
+export * from './chapters.js'

+ 18 - 1
packages/ffmpeg/src/ffprobe.ts

@@ -10,7 +10,7 @@ import { VideoResolution } from '@peertube/peertube-models'
 
 function ffprobePromise (path: string) {
   return new Promise<FfprobeData>((res, rej) => {
-    ffmpeg.ffprobe(path, (err, data) => {
+    ffmpeg.ffprobe(path, [ '-show_chapters' ], (err, data) => {
       if (err) return rej(err)
 
       return res(data)
@@ -168,10 +168,27 @@ async function getVideoStream (path: string, existingProbe?: FfprobeData) {
   return metadata.streams.find(s => s.codec_type === 'video')
 }
 
+// ---------------------------------------------------------------------------
+// Chapters
+// ---------------------------------------------------------------------------
+
+async function getChaptersFromContainer (path: string, existingProbe?: FfprobeData) {
+  const metadata = existingProbe || await ffprobePromise(path)
+
+  if (!Array.isArray(metadata?.chapters)) return []
+
+  return metadata.chapters
+    .map(c => ({
+      timecode: c.start_time,
+      title: c['TAG:title']
+    }))
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   getVideoStreamDimensionsInfo,
+  getChaptersFromContainer,
   getMaxAudioBitrate,
   getVideoStream,
   getVideoStreamDuration,

+ 2 - 1
packages/models/src/activitypub/context.ts

@@ -13,4 +13,5 @@ export type ContextType =
   'Flag' |
   'Actor' |
   'Collection' |
-  'WatchAction'
+  'WatchAction' |
+  'Chapters'

+ 1 - 0
packages/models/src/activitypub/objects/index.ts

@@ -4,6 +4,7 @@ export * from './cache-file-object.js'
 export * from './common-objects.js'
 export * from './playlist-element-object.js'
 export * from './playlist-object.js'
+export * from './video-chapters-object.js'
 export * from './video-comment-object.js'
 export * from './video-object.js'
 export * from './watch-action-object.js'

+ 11 - 0
packages/models/src/activitypub/objects/video-chapters-object.ts

@@ -0,0 +1,11 @@
+export interface VideoChaptersObject {
+  id: string
+  hasPart: VideoChapterObject[]
+}
+
+// Same as https://schema.org/hasPart
+export interface VideoChapterObject {
+  name: string
+  startOffset: number
+  endOffset: number
+}

+ 1 - 0
packages/models/src/activitypub/objects/video-object.ts

@@ -50,6 +50,7 @@ export interface VideoObject {
   dislikes: string
   shares: string
   comments: string
+  hasParts: string
 
   attributedTo: ActivityPubAttributedTo[]
 

+ 6 - 0
packages/models/src/videos/chapter/chapter-update.model.ts

@@ -0,0 +1,6 @@
+export interface VideoChapterUpdate {
+  chapters: {
+    timecode: number
+    title: string
+  }[]
+}

+ 4 - 0
packages/models/src/videos/chapter/chapter.model.ts

@@ -0,0 +1,4 @@
+export interface VideoChapter {
+  timecode: number
+  title: string
+}

+ 2 - 0
packages/models/src/videos/chapter/index.ts

@@ -0,0 +1,2 @@
+export * from './chapter-update.model.js'
+export * from './chapter.model.js'

+ 1 - 0
packages/models/src/videos/index.ts

@@ -12,6 +12,7 @@ export * from './rate/index.js'
 export * from './stats/index.js'
 export * from './transcoding/index.js'
 export * from './channel-sync/index.js'
+export * from './chapter/index.js'
 
 export * from './nsfw-policy.type.js'
 

+ 3 - 0
packages/server-commands/src/server/server.ts

@@ -30,6 +30,7 @@ import {
   ChangeOwnershipCommand,
   ChannelsCommand,
   ChannelSyncsCommand,
+  ChaptersCommand,
   CommentsCommand,
   HistoryCommand,
   ImportsCommand,
@@ -152,6 +153,7 @@ export class PeerTubeServer {
   videoPasswords?: VideoPasswordsCommand
 
   storyboard?: StoryboardCommand
+  chapters?: ChaptersCommand
 
   runners?: RunnersCommand
   runnerRegistrationTokens?: RunnerRegistrationTokensCommand
@@ -442,6 +444,7 @@ export class PeerTubeServer {
     this.registrations = new RegistrationsCommand(this)
 
     this.storyboard = new StoryboardCommand(this)
+    this.chapters = new ChaptersCommand(this)
 
     this.runners = new RunnersCommand(this)
     this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this)

+ 38 - 0
packages/server-commands/src/videos/chapters-command.ts

@@ -0,0 +1,38 @@
+import {
+  HttpStatusCode, VideoChapterUpdate, VideoChapters
+} from '@peertube/peertube-models'
+import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
+
+export class ChaptersCommand extends AbstractCommand {
+
+  list (options: OverrideCommandOptions & {
+    videoId: string | number
+  }) {
+    const path = '/api/v1/videos/' + options.videoId + '/chapters'
+
+    return this.getRequestBody<VideoChapters>({
+      ...options,
+
+      path,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.OK_200
+    })
+  }
+
+  update (options: OverrideCommandOptions & VideoChapterUpdate & {
+    videoId: number | string
+  }) {
+    const path = '/api/v1/videos/' + options.videoId + '/chapters'
+
+    return this.putBodyRequest({
+      ...options,
+
+      path,
+      fields: {
+        chapters: options.chapters
+      },
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+}

+ 1 - 0
packages/server-commands/src/videos/index.ts

@@ -3,6 +3,7 @@ export * from './captions-command.js'
 export * from './change-ownership-command.js'
 export * from './channels.js'
 export * from './channels-command.js'
+export * from './chapters-command.js'
 export * from './channel-syncs-command.js'
 export * from './comments-command.js'
 export * from './history-command.js'

BIN
packages/tests/fixtures/video_chapters.mp4


+ 1 - 0
packages/tests/src/api/check-params/index.ts

@@ -30,6 +30,7 @@ import './video-blacklist.js'
 import './video-captions.js'
 import './video-channel-syncs.js'
 import './video-channels.js'
+import './video-chapters.js'
 import './video-comments.js'
 import './video-files.js'
 import './video-imports.js'

+ 14 - 9
packages/tests/src/api/check-params/video-captions.ts

@@ -31,15 +31,7 @@ describe('Test video captions API validator', function () {
 
     video = await server.videos.upload()
     privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } })
-
-    {
-      const user = {
-        username: 'user1',
-        password: 'my super password'
-      }
-      await server.users.create({ username: user.username, password: user.password })
-      userAccessToken = await server.login.getAccessToken(user)
-    }
+    userAccessToken = await server.users.generateUserAndToken('user1')
   })
 
   describe('When adding video caption', function () {
@@ -120,6 +112,19 @@ describe('Test video captions API validator', function () {
       })
     })
 
+    it('Should fail with another user token', async function () {
+      const captionPath = path + video.uuid + '/captions/fr'
+      await makeUploadRequest({
+        method: 'PUT',
+        url: server.url,
+        path: captionPath,
+        token: userAccessToken,
+        fields,
+        attaches,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
     // We accept any file now
     // it('Should fail with an invalid captionfile extension', async function () {
     //   const attaches = {

+ 172 - 0
packages/tests/src/api/check-params/video-chapters.ts

@@ -0,0 +1,172 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { HttpStatusCode, Video, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
+import {
+  PeerTubeServer,
+  cleanupTests,
+  createSingleServer,
+  setAccessTokensToServers,
+  setDefaultVideoChannel
+} from '@peertube/peertube-server-commands'
+
+describe('Test videos chapters API validator', function () {
+  let server: PeerTubeServer
+  let video: VideoCreateResult
+  let live: Video
+  let privateVideo: VideoCreateResult
+  let userAccessToken: string
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(30000)
+
+    server = await createSingleServer(1)
+
+    await setAccessTokensToServers([ server ])
+    await setDefaultVideoChannel([ server ])
+
+    video = await server.videos.upload()
+    privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } })
+    userAccessToken = await server.users.generateUserAndToken('user1')
+
+    await server.config.enableLive({ allowReplay: false })
+
+    const res = await server.live.quickCreate({ saveReplay: false, permanentLive: false })
+    live = res.video
+  })
+
+  describe('When updating chapters', function () {
+
+    it('Should fail without a valid uuid', async function () {
+      await server.chapters.update({ videoId: '4da6fd', chapters: [], expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+    })
+
+    it('Should fail with an unknown id', async function () {
+      await server.chapters.update({
+        videoId: 'ce0801ef-7124-48df-9b22-b473ace78797',
+        chapters: [],
+        expectedStatus: HttpStatusCode.NOT_FOUND_404
+      })
+    })
+
+    it('Should fail without access token', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [],
+        token: null,
+        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+      })
+    })
+
+    it('Should fail with a bad access token', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [],
+        token: 'toto',
+        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+      })
+    })
+
+    it('Should fail with a another user access token', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [],
+        token: userAccessToken,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should fail with a wrong chapters param', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: 'hello' as any,
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail with a bad chapter title', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [ { title: 'hello', timecode: 21 }, { title: '', timecode: 21 } ],
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [ { title: 'hello', timecode: 21 }, { title: 'a'.repeat(150), timecode: 21 } ],
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail with a bad timecode', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [ { title: 'hello', timecode: 21 }, { title: 'title', timecode: -5 } ],
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [ { title: 'hello', timecode: 21 }, { title: 'title', timecode: 'hi' as any } ],
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail with non unique timecodes', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [ { title: 'hello', timecode: 21 }, { title: 'title', timecode: 22 }, { title: 'hello', timecode: 21 } ],
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail to create chapters on a live', async function () {
+      await server.chapters.update({
+        videoId: live.id,
+        chapters: [],
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: []
+      })
+
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [ { title: 'hello', timecode: 21 }, { title: 'hello 2', timecode: 35 } ]
+      })
+    })
+  })
+
+  describe('When listing chapters', function () {
+
+    it('Should fail without a valid uuid', async function () {
+      await server.chapters.list({ videoId: '4da6fd', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+    })
+
+    it('Should fail with an unknown id', async function () {
+      await server.chapters.list({ videoId: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+    })
+
+    it('Should not list private chapters to anyone', async function () {
+      await server.chapters.list({ videoId: privateVideo.uuid, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should not list private chapters to another user', async function () {
+      await server.chapters.list({ videoId: privateVideo.uuid, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+    })
+
+    it('Should list chapters', async function () {
+      await server.chapters.list({ videoId: privateVideo.uuid })
+      await server.chapters.list({ videoId: video.uuid })
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})

+ 1 - 0
packages/tests/src/api/videos/index.ts

@@ -4,6 +4,7 @@ import './single-server.js'
 import './video-captions.js'
 import './video-change-ownership.js'
 import './video-channels.js'
+import './video-chapters.js'
 import './channel-import-videos.js'
 import './video-channel-syncs.js'
 import './video-comments.js'

+ 342 - 0
packages/tests/src/api/videos/video-chapters.ts

@@ -0,0 +1,342 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { VideoChapter, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
+import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils'
+import {
+  cleanupTests,
+  createMultipleServers,
+  doubleFollow, PeerTubeServer, setAccessTokensToServers,
+  setDefaultVideoChannel,
+  waitJobs
+} from '@peertube/peertube-server-commands'
+import { FIXTURE_URLS } from '@tests/shared/tests.js'
+import { expect } from 'chai'
+
+describe('Test video chapters', function () {
+  let servers: PeerTubeServer[]
+
+  before(async function () {
+    this.timeout(120000)
+
+    servers = await createMultipleServers(2)
+    await setAccessTokensToServers(servers)
+    await setDefaultVideoChannel(servers)
+
+    await doubleFollow(servers[0], servers[1])
+  })
+
+  describe('Common tests', function () {
+    let video: VideoCreateResult
+
+    before(async function () {
+      this.timeout(120000)
+
+      video = await servers[0].videos.quickUpload({ name: 'video' })
+      await waitJobs(servers)
+    })
+
+    it('Should not have chapters', async function () {
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([])
+      }
+    })
+
+    it('Should set chaptets', async function () {
+      await servers[0].chapters.update({
+        videoId: video.uuid,
+        chapters: [
+          { title: 'chapter 1', timecode: 45 },
+          { title: 'chapter 2', timecode: 58 }
+        ]
+      })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([
+          { title: 'chapter 1', timecode: 45 },
+          { title: 'chapter 2', timecode: 58 }
+        ])
+      }
+    })
+
+    it('Should add new chapters', async function () {
+      await servers[0].chapters.update({
+        videoId: video.uuid,
+        chapters: [
+          { title: 'chapter 1', timecode: 45 },
+          { title: 'chapter 2', timecode: 46 },
+          { title: 'chapter 3', timecode: 58 }
+        ]
+      })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([
+          { title: 'chapter 1', timecode: 45 },
+          { title: 'chapter 2', timecode: 46 },
+          { title: 'chapter 3', timecode: 58 }
+        ])
+      }
+    })
+
+    it('Should delete all chapters', async function () {
+      await servers[0].chapters.update({ videoId: video.uuid, chapters: [] })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([])
+      }
+    })
+  })
+
+  describe('With chapters in description', function () {
+    const description = 'this is a super description\n' +
+      '00:00 chapter 1\n' +
+      '00:03 chapter 2\n' +
+      '00:04 chapter 3\n'
+
+    function checkChapters (chapters: VideoChapter[]) {
+      expect(chapters).to.deep.equal([
+        {
+          timecode: 0,
+          title: 'chapter 1'
+        },
+        {
+          timecode: 3,
+          title: 'chapter 2'
+        },
+        {
+          timecode: 4,
+          title: 'chapter 3'
+        }
+      ])
+    }
+
+    it('Should upload a video with chapters in description', async function () {
+      const video = await servers[0].videos.upload({ attributes: { name: 'description', description } })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        checkChapters(chapters)
+      }
+    })
+
+    it('Should update a video description and automatically add chapters', async function () {
+      const video = await servers[0].videos.quickUpload({ name: 'update description' })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([])
+      }
+
+      await servers[0].videos.update({ id: video.uuid, attributes: { description } })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        checkChapters(chapters)
+      }
+    })
+
+    it('Should update a video description but not automatically add chapters since the video already has chapters', async function () {
+      const video = await servers[0].videos.quickUpload({ name: 'update description' })
+
+      await servers[0].chapters.update({ videoId: video.uuid, chapters: [ { timecode: 5, title: 'chapter 1' } ] })
+      await servers[0].videos.update({ id: video.uuid, attributes: { description } })
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([ { timecode: 5, title: 'chapter 1' } ])
+      }
+    })
+
+    it('Should update multiple times chapters from description', async function () {
+      const video = await servers[0].videos.quickUpload({ name: 'update description' })
+
+      await servers[0].videos.update({ id: video.uuid, attributes: { description } })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        checkChapters(chapters)
+      }
+
+      await servers[0].videos.update({ id: video.uuid, attributes: { description: '00:01 chapter 1' } })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([ { timecode: 1, title: 'chapter 1' } ])
+      }
+
+      await servers[0].videos.update({ id: video.uuid, attributes: { description: 'null description' } })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([])
+      }
+    })
+  })
+
+  describe('With upload', function () {
+
+    it('Should upload a mp4 containing chapters and automatically add them', async function () {
+      const video = await servers[0].videos.quickUpload({ fixture: 'video_chapters.mp4', name: 'chapters' })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([
+          {
+            timecode: 0,
+            title: 'Chapter 1'
+          },
+          {
+            timecode: 2,
+            title: 'Chapter 2'
+          },
+          {
+            timecode: 4,
+            title: 'Chapter 3'
+          }
+        ])
+      }
+    })
+  })
+
+  describe('With URL import', function () {
+    if (areHttpImportTestsDisabled()) return
+
+    it('Should detect chapters from youtube URL import', async function () {
+      this.timeout(120000)
+
+      const attributes = {
+        channelId: servers[0].store.channel.id,
+        privacy: VideoPrivacy.PUBLIC,
+        targetUrl: FIXTURE_URLS.youtubeChapters,
+        description: 'this is a super description\n'
+      }
+      const { video } = await servers[0].imports.importVideo({ attributes })
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([
+          {
+            timecode: 0,
+            title: 'chapter 1'
+          },
+          {
+            timecode: 15,
+            title: 'chapter 2'
+          },
+          {
+            timecode: 35,
+            title: 'chapter 3'
+          },
+          {
+            timecode: 40,
+            title: 'chapter 4'
+          }
+        ])
+      }
+    })
+
+    it('Should have overriden description priority from youtube URL import', async function () {
+      this.timeout(120000)
+
+      const attributes = {
+        channelId: servers[0].store.channel.id,
+        privacy: VideoPrivacy.PUBLIC,
+        targetUrl: FIXTURE_URLS.youtubeChapters,
+        description: 'this is a super description\n' +
+          '00:00 chapter 1\n' +
+          '00:03 chapter 2\n' +
+          '00:04 chapter 3\n'
+      }
+      const { video } = await servers[0].imports.importVideo({ attributes })
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([
+          {
+            timecode: 0,
+            title: 'chapter 1'
+          },
+          {
+            timecode: 3,
+            title: 'chapter 2'
+          },
+          {
+            timecode: 4,
+            title: 'chapter 3'
+          }
+        ])
+      }
+    })
+
+    it('Should detect chapters from raw URL import', async function () {
+      this.timeout(120000)
+
+      const attributes = {
+        channelId: servers[0].store.channel.id,
+        privacy: VideoPrivacy.PUBLIC,
+        targetUrl: FIXTURE_URLS.chatersVideo
+      }
+      const { video } = await servers[0].imports.importVideo({ attributes })
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([
+          {
+            timecode: 0,
+            title: 'Chapter 1'
+          },
+          {
+            timecode: 2,
+            title: 'Chapter 2'
+          },
+          {
+            timecode: 4,
+            title: 'Chapter 3'
+          }
+        ])
+      }
+    })
+  })
+
+  // TODO: test torrent import too
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})

+ 26 - 1
packages/tests/src/server-helpers/core-utils.ts

@@ -3,7 +3,7 @@
 import { expect } from 'chai'
 import snakeCase from 'lodash-es/snakeCase.js'
 import validator from 'validator'
-import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate } from '@peertube/peertube-core-utils'
+import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, parseChapters } from '@peertube/peertube-core-utils'
 import { VideoResolution } from '@peertube/peertube-models'
 import { objectConverter, parseBytes, parseDurationToMs, parseSemVersion } from '@peertube/peertube-server/server/helpers/core-utils.js'
 
@@ -199,3 +199,28 @@ describe('Parse semantic version string', function () {
     expect(actual.patch).to.equal(0)
   })
 })
+
+describe('Extract chapters', function () {
+
+  it('Should not extract chapters', function () {
+    expect(parseChapters('my super description\nno?')).to.deep.equal([])
+    expect(parseChapters('m00:00 super description\nno?')).to.deep.equal([])
+    expect(parseChapters('00:00super description\nno?')).to.deep.equal([])
+  })
+
+  it('Should extract chapters', function () {
+    expect(parseChapters('00:00 coucou')).to.deep.equal([ { timecode: 0, title: 'coucou' } ])
+    expect(parseChapters('my super description\n\n00:01:30 chapter 1\n00:01:35 chapter 2')).to.deep.equal([
+      { timecode: 90, title: 'chapter 1' },
+      { timecode: 95, title: 'chapter 2' }
+    ])
+    expect(parseChapters('hi\n\n00:01:30 chapter 1\n00:01:35 chapter 2\nhi')).to.deep.equal([
+      { timecode: 90, title: 'chapter 1' },
+      { timecode: 95, title: 'chapter 2' }
+    ])
+    expect(parseChapters('hi\n\n00:01:30 chapter 1\n00:01:35 chapter 2\nhi\n00:01:40 chapter 3')).to.deep.equal([
+      { timecode: 90, title: 'chapter 1' },
+      { timecode: 95, title: 'chapter 2' }
+    ])
+  })
+})

+ 3 - 0
packages/tests/src/shared/tests.ts

@@ -3,6 +3,7 @@ const FIXTURE_URLS = {
   peertube_short: 'https://peertube2.cpy.re/w/3fbif9S3WmtTP8gGsC5HBd',
 
   youtube: 'https://www.youtube.com/watch?v=msX3jv1XdvM',
+  youtubeChapters: 'https://www.youtube.com/watch?v=TL9P-Er7ils',
 
   /**
    * The video is used to check format-selection correctness wrt. HDR,
@@ -26,6 +27,8 @@ const FIXTURE_URLS = {
   goodVideo: 'https://download.cpy.re/peertube/good_video.mp4',
   goodVideo720: 'https://download.cpy.re/peertube/good_video_720.mp4',
 
+  chatersVideo: 'https://download.cpy.re/peertube/video_chapters.mp4',
+
   file4K: 'https://download.cpy.re/peertube/4k_file.txt'
 }
 

+ 2 - 0
packages/typescript-utils/src/types.ts

@@ -43,3 +43,5 @@ export type DeepOmit<T, K> = T extends Primitive ? T : DeepOmitHelper<T, Exclude
 export type DeepOmitArray<T extends any[], K> = {
   [P in keyof T]: DeepOmit<T[P], K>
 }
+
+export type Unpacked<T> = T extends (infer U)[] ? U : T

+ 63 - 2
server/server/controllers/activitypub/client.ts

@@ -1,6 +1,13 @@
 import cors from 'cors'
 import express from 'express'
-import { VideoCommentObject, VideoPlaylistPrivacy, VideoPrivacy, VideoRateType } from '@peertube/peertube-models'
+import {
+  VideoChapterObject,
+  VideoChaptersObject,
+  VideoCommentObject,
+  VideoPlaylistPrivacy,
+  VideoPrivacy,
+  VideoRateType
+} from '@peertube/peertube-models'
 import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js'
 import { getContextFilter } from '@server/lib/activitypub/context.js'
 import { getServerActor } from '@server/models/application/application.js'
@@ -12,12 +19,18 @@ import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/act
 import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js'
 import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js'
 import {
+  getLocalVideoChaptersActivityPubUrl,
   getLocalVideoCommentsActivityPubUrl,
   getLocalVideoDislikesActivityPubUrl,
   getLocalVideoLikesActivityPubUrl,
   getLocalVideoSharesActivityPubUrl
 } from '../../lib/activitypub/url.js'
-import { cacheRoute } from '../../middlewares/cache/cache.js'
+import {
+  apVideoChaptersSetCacheKey,
+  buildAPVideoChaptersGroupsCache,
+  cacheRoute,
+  cacheRouteFactory
+} from '../../middlewares/cache/cache.js'
 import {
   activityPubRateLimiter,
   asyncMiddleware,
@@ -42,6 +55,8 @@ import { VideoCommentModel } from '../../models/video/video-comment.js'
 import { VideoPlaylistModel } from '../../models/video/video-playlist.js'
 import { VideoShareModel } from '../../models/video/video-share.js'
 import { activityPubResponse } from './utils.js'
+import { VideoChapterModel } from '@server/models/video/video-chapter.js'
+import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
 
 const activityPubClientRouter = express.Router()
 activityPubClientRouter.use(cors())
@@ -145,6 +160,27 @@ activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity
   asyncMiddleware(videoCommentController)
 )
 
+// ---------------------------------------------------------------------------
+
+const { middleware: chaptersCacheRouteMiddleware, instance: chaptersApiCache } = cacheRouteFactory()
+
+InternalEventEmitter.Instance.on('chapters-updated', ({ video }) => {
+  if (video.remote) return
+
+  chaptersApiCache.clearGroupSafe(buildAPVideoChaptersGroupsCache({ videoId: video.uuid }))
+})
+
+activityPubClientRouter.get('/videos/watch/:id/chapters',
+  executeIfActivityPub,
+  activityPubRateLimiter,
+  apVideoChaptersSetCacheKey,
+  chaptersCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS),
+  asyncMiddleware(videosCustomGetValidator('only-video')),
+  asyncMiddleware(videoChaptersController)
+)
+
+// ---------------------------------------------------------------------------
+
 activityPubClientRouter.get(
   [ '/video-channels/:nameWithHost', '/video-channels/:nameWithHost/videos', '/c/:nameWithHost', '/c/:nameWithHost/videos' ],
   executeIfActivityPub,
@@ -390,6 +426,31 @@ async function videoCommentController (req: express.Request, res: express.Respon
   return activityPubResponse(activityPubContextify(videoCommentObject, 'Comment', getContextFilter()), res)
 }
 
+async function videoChaptersController (req: express.Request, res: express.Response) {
+  const video = res.locals.onlyVideo
+
+  if (redirectIfNotOwned(video.url, res)) return
+
+  const chapters = await VideoChapterModel.listChaptersOfVideo(video.id)
+
+  const hasPart: VideoChapterObject[] = []
+
+  if (chapters.length !== 0) {
+    for (let i = 0; i < chapters.length - 1; i++) {
+      hasPart.push(chapters[i].toActivityPubJSON({ video, nextChapter: chapters[i + 1] }))
+    }
+
+    hasPart.push(chapters[chapters.length - 1].toActivityPubJSON({ video: res.locals.onlyVideo, nextChapter: null }))
+  }
+
+  const chaptersObject: VideoChaptersObject = {
+    id: getLocalVideoChaptersActivityPubUrl(video),
+    hasPart
+  }
+
+  return activityPubResponse(activityPubContextify(chaptersObject, 'Chapters', getContextFilter()), res)
+}
+
 async function videoRedundancyController (req: express.Request, res: express.Response) {
   const videoRedundancy = res.locals.videoRedundancy
 

+ 51 - 0
server/server/controllers/api/videos/chapters.ts

@@ -0,0 +1,51 @@
+import express from 'express'
+import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares/index.js'
+import { updateVideoChaptersValidator, videosCustomGetValidator } from '../../../middlewares/validators/index.js'
+import { VideoChapterModel } from '@server/models/video/video-chapter.js'
+import { HttpStatusCode, VideoChapterUpdate } from '@peertube/peertube-models'
+import { sequelizeTypescript } from '@server/initializers/database.js'
+import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
+import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js'
+import { replaceChapters } from '@server/lib/video-chapters.js'
+
+const videoChaptersRouter = express.Router()
+
+videoChaptersRouter.get('/:id/chapters',
+  asyncMiddleware(videosCustomGetValidator('only-video')),
+  asyncMiddleware(listVideoChapters)
+)
+
+videoChaptersRouter.put('/:videoId/chapters',
+  authenticate,
+  asyncMiddleware(updateVideoChaptersValidator),
+  asyncRetryTransactionMiddleware(replaceVideoChapters)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  videoChaptersRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function listVideoChapters (req: express.Request, res: express.Response) {
+  const chapters = await VideoChapterModel.listChaptersOfVideo(res.locals.onlyVideo.id)
+
+  return res.json({ chapters: chapters.map(c => c.toFormattedJSON()) })
+}
+
+async function replaceVideoChapters (req: express.Request, res: express.Response) {
+  const body = req.body as VideoChapterUpdate
+  const video = res.locals.videoAll
+
+  await retryTransactionWrapper(() => {
+    return sequelizeTypescript.transaction(async t => {
+      await replaceChapters({ video, chapters: body.chapters, transaction: t })
+
+      await federateVideoIfNeeded(video, false, t)
+    })
+  })
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}

+ 2 - 0
server/server/controllers/api/videos/index.ts

@@ -49,6 +49,7 @@ import { transcodingRouter } from './transcoding.js'
 import { updateRouter } from './update.js'
 import { uploadRouter } from './upload.js'
 import { viewRouter } from './view.js'
+import { videoChaptersRouter } from './chapters.js'
 
 const auditLogger = auditLoggerFactory('videos')
 const videosRouter = express.Router()
@@ -73,6 +74,7 @@ videosRouter.use('/', tokenRouter)
 videosRouter.use('/', videoPasswordRouter)
 videosRouter.use('/', storyboardRouter)
 videosRouter.use('/', videoSourceRouter)
+videosRouter.use('/', videoChaptersRouter)
 
 videosRouter.get('/categories',
   openapiOperationDoc({ operationId: 'getCategories' }),

+ 11 - 0
server/server/controllers/api/videos/update.ts

@@ -22,6 +22,7 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist.js'
 import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js'
 import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
 import { VideoModel } from '../../../models/video/video.js'
+import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
 
 const lTags = loggerTagsFactory('api', 'video')
 const auditLogger = auditLoggerFactory('videos')
@@ -67,6 +68,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
       // Refresh video since thumbnails to prevent concurrent updates
       const video = await VideoModel.loadFull(videoFromReq.id, t)
 
+      const oldDescription = video.description
       const oldVideoChannel = video.VideoChannel
 
       const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes<VideoModel>)[] = [
@@ -127,6 +129,15 @@ async function updateVideo (req: express.Request, res: express.Response) {
       // Schedule an update in the future?
       await updateSchedule(videoInstanceUpdated, videoInfoToUpdate, t)
 
+      if (oldDescription !== video.description) {
+        await replaceChaptersFromDescriptionIfNeeded({
+          newDescription: videoInstanceUpdated.description,
+          transaction: t,
+          video,
+          oldDescription
+        })
+      }
+
       await autoBlacklistVideoIfNeeded({
         video: videoInstanceUpdated,
         user: res.locals.oauth.token.User,

+ 9 - 0
server/server/controllers/api/videos/upload.ts

@@ -34,6 +34,8 @@ import {
 } from '../../../middlewares/index.js'
 import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
 import { VideoModel } from '../../../models/video/video.js'
+import { getChaptersFromContainer } from '@peertube/peertube-ffmpeg'
+import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
 
 const lTags = loggerTagsFactory('api', 'video')
 const auditLogger = auditLoggerFactory('videos')
@@ -143,6 +145,9 @@ async function addVideo (options: {
   const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' })
   const originalFilename = videoPhysicalFile.originalname
 
+  const containerChapters = await getChaptersFromContainer(videoPhysicalFile.path)
+  logger.debug(`Got ${containerChapters.length} chapters from video "${video.name}" container`, { containerChapters, ...lTags(video.uuid) })
+
   // Move physical file
   const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
   await move(videoPhysicalFile.path, destination)
@@ -188,6 +193,10 @@ async function addVideo (options: {
       }, sequelizeOptions)
     }
 
+    if (!await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction: t })) {
+      await replaceChapters({ video, chapters: containerChapters, transaction: t })
+    }
+
     await autoBlacklistVideoIfNeeded({
       video,
       user,

+ 10 - 1
server/server/helpers/activity-pub-utils.ts

@@ -79,6 +79,8 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
 
     uploadDate: 'sc:uploadDate',
 
+    hasParts: 'sc:hasParts',
+
     views: {
       '@type': 'sc:Number',
       '@id': 'pt:views'
@@ -195,7 +197,14 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
   Announce: buildContext(),
   Comment: buildContext(),
   Delete: buildContext(),
-  Rate: buildContext()
+  Rate: buildContext(),
+
+  Chapters: buildContext({
+    name: 'sc:name',
+    hasPart: 'sc:hasPart',
+    endOffset: 'sc:endOffset',
+    startOffset: 'sc:startOffset'
+  })
 }
 
 async function getContextData (type: ContextType, contextFilter: ContextFilter) {

+ 15 - 0
server/server/helpers/custom-validators/activitypub/video-chapters.ts

@@ -0,0 +1,15 @@
+import { isArray } from '../misc.js'
+import { isVideoChapterTitleValid, isVideoChapterTimecodeValid } from '../video-chapters.js'
+import { isActivityPubUrlValid } from './misc.js'
+import { VideoChaptersObject } from '@peertube/peertube-models'
+
+export function isVideoChaptersObjectValid (object: VideoChaptersObject) {
+  if (!object) return false
+  if (!isActivityPubUrlValid(object.id)) return false
+
+  if (!isArray(object.hasPart)) return false
+
+  return object.hasPart.every(part => {
+    return isVideoChapterTitleValid(part.name) && isVideoChapterTimecodeValid(part.startOffset)
+  })
+}

+ 26 - 0
server/server/helpers/custom-validators/video-chapters.ts

@@ -0,0 +1,26 @@
+import { isArray } from './misc.js'
+import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models'
+import { Unpacked } from '@peertube/peertube-typescript-utils'
+import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
+import validator from 'validator'
+
+export function areVideoChaptersValid (value: VideoChapter[]) {
+  if (!isArray(value)) return false
+  if (!value.every(v => isVideoChapterValid(v))) return false
+
+  const timecodes = value.map(c => c.timecode)
+
+  return new Set(timecodes).size === timecodes.length
+}
+
+export function isVideoChapterValid (value: Unpacked<VideoChapterUpdate['chapters']>) {
+  return isVideoChapterTimecodeValid(value.timecode) && isVideoChapterTitleValid(value.title)
+}
+
+export function isVideoChapterTitleValid (value: any) {
+  return validator.default.isLength(value + '', CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE)
+}
+
+export function isVideoChapterTimecodeValid (value: any) {
+  return validator.default.isInt(value + '', { min: 0 })
+}

+ 10 - 1
server/server/helpers/youtube-dl/youtube-dl-info-builder.ts

@@ -1,6 +1,7 @@
 import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js'
 import { peertubeTruncate } from '../core-utils.js'
 import { isUrlValid } from '../custom-validators/activitypub/misc.js'
+import { isArray } from '../custom-validators/misc.js'
 
 export type YoutubeDLInfo = {
   name?: string
@@ -16,6 +17,11 @@ export type YoutubeDLInfo = {
   webpageUrl?: string
 
   urls?: string[]
+
+  chapters?: {
+    timecode: number
+    title: string
+  }[]
 }
 
 export class YoutubeDLInfoBuilder {
@@ -83,7 +89,10 @@ export class YoutubeDLInfoBuilder {
       urls: this.buildAvailableUrl(obj),
       originallyPublishedAtWithoutTime: this.buildOriginallyPublishedAt(obj),
       ext: obj.ext,
-      webpageUrl: obj.webpage_url
+      webpageUrl: obj.webpage_url,
+      chapters: isArray(obj.chapters)
+        ? obj.chapters.map((c: { start_time: number, title: string }) => ({ timecode: c.start_time, title: c.title }))
+        : []
     }
   }
 

+ 3 - 0
server/server/initializers/constants.ts

@@ -465,6 +465,9 @@ const CONSTRAINTS_FIELDS = {
   },
   VIDEO_PASSWORD: {
     LENGTH: { min: 2, max: 100 }
+  },
+  VIDEO_CHAPTERS: {
+    TITLE: { min: 1, max: 100 } // Length
   }
 }
 

+ 2 - 0
server/server/initializers/database.ts

@@ -59,6 +59,7 @@ import { VideoTagModel } from '../models/video/video-tag.js'
 import { VideoModel } from '../models/video/video.js'
 import { VideoViewModel } from '../models/view/video-view.js'
 import { CONFIG } from './config.js'
+import { VideoChapterModel } from '@server/models/video/video-chapter.js'
 
 pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string
 
@@ -137,6 +138,7 @@ async function initDatabaseModels (silent: boolean) {
     VideoShareModel,
     VideoFileModel,
     VideoSourceModel,
+    VideoChapterModel,
     VideoCaptionModel,
     VideoBlacklistModel,
     VideoTagModel,

+ 5 - 0
server/server/lib/activitypub/url.ts

@@ -80,6 +80,10 @@ function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) {
   return video.url + '/comments'
 }
 
+function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
+  return video.url + '/chapters'
+}
+
 function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
   return video.url + '/likes'
 }
@@ -167,6 +171,7 @@ export {
   getDeleteActivityPubUrl,
   getLocalVideoSharesActivityPubUrl,
   getLocalVideoCommentsActivityPubUrl,
+  getLocalVideoChaptersActivityPubUrl,
   getLocalVideoLikesActivityPubUrl,
   getLocalVideoDislikesActivityPubUrl,
   getLocalVideoViewerActivityPubUrl,

+ 33 - 3
server/server/lib/activitypub/videos/shared/abstract-builder.ts

@@ -1,6 +1,12 @@
 import { CreationAttributes, Transaction } from 'sequelize'
-import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType_Type } from '@peertube/peertube-models'
-import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils.js'
+import {
+  ActivityTagObject,
+  ThumbnailType,
+  VideoChaptersObject,
+  VideoObject,
+  VideoStreamingPlaylistType_Type
+} from '@peertube/peertube-models'
+import { deleteAllModels, filterNonExistingModels, retryTransactionWrapper } from '@server/helpers/database-utils.js'
 import { logger, LoggerTagsFn } from '@server/helpers/logger.js'
 import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js'
 import { setVideoTags } from '@server/lib/video.js'
@@ -29,6 +35,10 @@ import {
   getThumbnailFromIcons
 } from './object-to-model-attributes.js'
 import { getTrackerUrls, setVideoTrackers } from './trackers.js'
+import { fetchAP } from '../../activity.js'
+import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js'
+import { sequelizeTypescript } from '@server/initializers/database.js'
+import { replaceChapters } from '@server/lib/video-chapters.js'
 
 export abstract class APVideoAbstractBuilder {
   protected abstract videoObject: VideoObject
@@ -44,7 +54,7 @@ export abstract class APVideoAbstractBuilder {
   protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) {
     const miniatureIcon = getThumbnailFromIcons(this.videoObject)
     if (!miniatureIcon) {
-      logger.warn('Cannot find thumbnail in video object', { object: this.videoObject })
+      logger.warn('Cannot find thumbnail in video object', { object: this.videoObject, ...this.lTags() })
       return undefined
     }
 
@@ -138,6 +148,26 @@ export abstract class APVideoAbstractBuilder {
     video.VideoFiles = await Promise.all(upsertTasks)
   }
 
+  protected async updateChaptersOutsideTransaction (video: MVideoFullLight) {
+    if (!this.videoObject.hasParts || typeof this.videoObject.hasParts !== 'string') return
+
+    const { body } = await fetchAP<VideoChaptersObject>(this.videoObject.hasParts)
+    if (!isVideoChaptersObjectValid(body)) {
+      logger.warn('Chapters AP object is not valid, skipping', { body, ...this.lTags() })
+      return
+    }
+
+    logger.debug('Fetched chapters AP object', { body, ...this.lTags() })
+
+    return retryTransactionWrapper(() => {
+      return sequelizeTypescript.transaction(async t => {
+        const chapters = body.hasPart.map(p => ({ title: p.name, timecode: p.startOffset }))
+
+        await replaceChapters({ chapters, transaction: t, video })
+      })
+    })
+  }
+
   protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) {
     const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject)
     const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))

+ 2 - 0
server/server/lib/activitypub/videos/shared/creator.ts

@@ -60,6 +60,8 @@ export class APVideoCreator extends APVideoAbstractBuilder {
       return { autoBlacklisted, videoCreated }
     })
 
+    await this.updateChaptersOutsideTransaction(videoCreated)
+
     return { autoBlacklisted, videoCreated }
   }
 }

+ 2 - 0
server/server/lib/activitypub/videos/updater.ts

@@ -77,6 +77,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
 
       await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
 
+      await this.updateChaptersOutsideTransaction(videoUpdated)
+
       await autoBlacklistVideoIfNeeded({
         video: videoUpdated,
         user: undefined,

+ 3 - 1
server/server/lib/internal-event-emitter.ts

@@ -1,4 +1,4 @@
-import { MChannel, MVideo } from '@server/types/models/index.js'
+import { MChannel, MVideo, MVideoImmutable } from '@server/types/models/index.js'
 import { EventEmitter } from 'events'
 
 export interface PeerTubeInternalEvents {
@@ -9,6 +9,8 @@ export interface PeerTubeInternalEvents {
   'channel-created': (options: { channel: MChannel }) => void
   'channel-updated': (options: { channel: MChannel }) => void
   'channel-deleted': (options: { channel: MChannel }) => void
+
+  'chapters-updated': (options: { video: MVideoImmutable }) => void
 }
 
 declare interface InternalEventEmitter {

+ 6 - 0
server/server/lib/job-queue/handlers/video-import.ts

@@ -32,6 +32,7 @@ import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImpo
 import { getLowercaseExtension } from '@peertube/peertube-node-utils'
 import {
   ffprobePromise,
+  getChaptersFromContainer,
   getVideoStreamDimensionsInfo,
   getVideoStreamDuration,
   getVideoStreamFPS,
@@ -49,6 +50,7 @@ import { federateVideoIfNeeded } from '../../activitypub/videos/index.js'
 import { Notifier } from '../../notifier/index.js'
 import { generateLocalVideoMiniature } from '../../thumbnail.js'
 import { JobQueue } from '../job-queue.js'
+import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js'
 
 async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> {
   const payload = job.data as VideoImportPayload
@@ -150,6 +152,8 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
     const fps = await getVideoStreamFPS(tempVideoPath, probe)
     const duration = await getVideoStreamDuration(tempVideoPath, probe)
 
+    const containerChapters = await getChaptersFromContainer(tempVideoPath, probe)
+
     // Prepare video file object for creation in database
     const fileExt = getLowercaseExtension(tempVideoPath)
     const videoFileData = {
@@ -228,6 +232,8 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
           if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t)
           if (previewModel) await video.addAndSaveThumbnail(previewModel, t)
 
+          await replaceChaptersIfNotExist({ video, chapters: containerChapters, transaction: t })
+
           // Now we can federate the video (reload from database, we need more attributes)
           const videoForFederation = await VideoModel.loadFull(video.uuid, t)
           await federateVideoIfNeeded(videoForFederation, true, t)

+ 99 - 0
server/server/lib/video-chapters.ts

@@ -0,0 +1,99 @@
+import { parseChapters, sortBy } from '@peertube/peertube-core-utils'
+import { VideoChapter } from '@peertube/peertube-models'
+import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
+import { VideoChapterModel } from '@server/models/video/video-chapter.js'
+import { MVideoImmutable } from '@server/types/models/index.js'
+import { Transaction } from 'sequelize'
+import { InternalEventEmitter } from './internal-event-emitter.js'
+
+const lTags = loggerTagsFactory('video', 'chapters')
+
+export async function replaceChapters (options: {
+  video: MVideoImmutable
+  chapters: VideoChapter[]
+  transaction: Transaction
+}) {
+  const { chapters, transaction, video } = options
+
+  await VideoChapterModel.deleteChapters(video.id, transaction)
+
+  await createChapters({ videoId: video.id, chapters, transaction })
+
+  InternalEventEmitter.Instance.emit('chapters-updated', { video })
+}
+
+export async function replaceChaptersIfNotExist (options: {
+  video: MVideoImmutable
+  chapters: VideoChapter[]
+  transaction: Transaction
+}) {
+  const { chapters, transaction, video } = options
+
+  if (await VideoChapterModel.hasVideoChapters(video.id, transaction)) return
+
+  await createChapters({ videoId: video.id, chapters, transaction })
+
+  InternalEventEmitter.Instance.emit('chapters-updated', { video })
+}
+
+export async function replaceChaptersFromDescriptionIfNeeded (options: {
+  oldDescription?: string
+  newDescription: string
+  video: MVideoImmutable
+  transaction: Transaction
+}) {
+  const { transaction, video, newDescription, oldDescription = '' } = options
+
+  const chaptersFromOldDescription = sortBy(parseChapters(oldDescription), 'timecode')
+  const existingChapters = await VideoChapterModel.listChaptersOfVideo(video.id, transaction)
+
+  logger.debug(
+    'Check if we replace chapters from description',
+    { oldDescription, chaptersFromOldDescription, newDescription, existingChapters, ...lTags(video.uuid) }
+  )
+
+  // Then we can update chapters from the new description
+  if (areSameChapters(chaptersFromOldDescription, existingChapters)) {
+    const chaptersFromNewDescription = sortBy(parseChapters(newDescription), 'timecode')
+    if (chaptersFromOldDescription.length === 0 && chaptersFromNewDescription.length === 0) return false
+
+    await replaceChapters({ video, chapters: chaptersFromNewDescription, transaction })
+
+    logger.info('Replaced chapters of video ' + video.uuid, { chaptersFromNewDescription, ...lTags(video.uuid) })
+
+    return true
+  }
+
+  return false
+}
+
+// ---------------------------------------------------------------------------
+// Private
+// ---------------------------------------------------------------------------
+
+async function createChapters (options: {
+  videoId: number
+  chapters: VideoChapter[]
+  transaction: Transaction
+}) {
+  const { chapters, transaction, videoId } = options
+
+  for (const chapter of chapters) {
+    await VideoChapterModel.create({
+      title: chapter.title,
+      timecode: chapter.timecode,
+      videoId
+    }, { transaction })
+  }
+}
+
+function areSameChapters (chapters1: VideoChapter[], chapters2: VideoChapter[]) {
+  if (chapters1.length !== chapters2.length) return false
+
+  for (let i = 0; i < chapters1.length; i++) {
+    if (chapters1[i].timecode !== chapters2[i].timecode) return false
+    if (chapters1[i].title !== chapters2[i].title) return false
+  }
+
+  return true
+}

+ 24 - 0
server/server/lib/video-pre-import.ts

@@ -39,6 +39,7 @@ import {
 } from '@server/types/models/index.js'
 import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
 import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js'
+import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js'
 
 class YoutubeDlImportError extends Error {
   code: YoutubeDlImportError.CODE
@@ -227,6 +228,29 @@ async function buildYoutubeDLImport (options: {
     videoPasswords: importDataOverride.videoPasswords
   })
 
+  await sequelizeTypescript.transaction(async transaction => {
+    // Priority to explicitely set description
+    if (importDataOverride?.description) {
+      const inserted = await replaceChaptersFromDescriptionIfNeeded({ newDescription: importDataOverride.description, video, transaction })
+      if (inserted) return
+    }
+
+    // Then priority to youtube-dl chapters
+    if (youtubeDLInfo.chapters.length !== 0) {
+      logger.info(
+        `Inserting chapters in video ${video.uuid} from youtube-dl`,
+        { chapters: youtubeDLInfo.chapters, tags: [ 'chapters', video.uuid ] }
+      )
+
+      await replaceChapters({ video, chapters: youtubeDLInfo.chapters, transaction })
+      return
+    }
+
+    if (video.description) {
+      await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction })
+    }
+  })
+
   // Get video subtitles
   await processYoutubeSubtitles(youtubeDL, targetUrl, video.id)
 

+ 29 - 9
server/server/middlewares/cache/cache.ts

@@ -1,3 +1,4 @@
+import express from 'express'
 import { HttpStatusCode } from '@peertube/peertube-models'
 import { ApiCache, APICacheOptions } from './shared/index.js'
 
@@ -8,13 +9,13 @@ const defaultOptions: APICacheOptions = {
   ]
 }
 
-function cacheRoute (duration: string) {
+export function cacheRoute (duration: string) {
   const instance = new ApiCache(defaultOptions)
 
   return instance.buildMiddleware(duration)
 }
 
-function cacheRouteFactory (options: APICacheOptions) {
+export function cacheRouteFactory (options: APICacheOptions = {}) {
   const instance = new ApiCache({ ...defaultOptions, ...options })
 
   return { instance, middleware: instance.buildMiddleware.bind(instance) }
@@ -22,17 +23,36 @@ function cacheRouteFactory (options: APICacheOptions) {
 
 // ---------------------------------------------------------------------------
 
-function buildPodcastGroupsCache (options: {
+export function buildPodcastGroupsCache (options: {
   channelId: number
 }) {
   return 'podcast-feed-' + options.channelId
 }
 
-// ---------------------------------------------------------------------------
+export function buildAPVideoChaptersGroupsCache (options: {
+  videoId: number | string
+}) {
+  return 'ap-video-chapters-' + options.videoId
+}
 
-export {
-  cacheRoute,
-  cacheRouteFactory,
+// ---------------------------------------------------------------------------
 
-  buildPodcastGroupsCache
-}
+export const videoFeedsPodcastSetCacheKey = [
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (req.query.videoChannelId) {
+      res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
+    }
+
+    return next()
+  }
+]
+
+export const apVideoChaptersSetCacheKey = [
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (req.params.id) {
+      res.locals.apicacheGroups = [ buildAPVideoChaptersGroupsCache({ videoId: req.params.id }) ]
+    }
+
+    return next()
+  }
+]

+ 0 - 11
server/server/middlewares/validators/feeds.ts

@@ -3,7 +3,6 @@ import { param, query } from 'express-validator'
 import { HttpStatusCode } from '@peertube/peertube-models'
 import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js'
 import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js'
-import { buildPodcastGroupsCache } from '../cache/index.js'
 import {
   areValidationErrors,
   checkCanSeeVideo,
@@ -114,15 +113,6 @@ const videoFeedsPodcastValidator = [
   }
 ]
 
-const videoFeedsPodcastSetCacheKey = [
-  (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    if (req.query.videoChannelId) {
-      res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
-    }
-
-    return next()
-  }
-]
 // ---------------------------------------------------------------------------
 
 const videoSubscriptionFeedsValidator = [
@@ -173,6 +163,5 @@ export {
   feedsAccountOrChannelFiltersValidator,
   videoFeedsPodcastValidator,
   videoSubscriptionFeedsValidator,
-  videoFeedsPodcastSetCacheKey,
   videoCommentsFeedsValidator
 }

+ 1 - 0
server/server/middlewares/validators/videos/index.ts

@@ -2,6 +2,7 @@ export * from './video-blacklist.js'
 export * from './video-captions.js'
 export * from './video-channel-sync.js'
 export * from './video-channels.js'
+export * from './video-chapters.js'
 export * from './video-comments.js'
 export * from './video-files.js'
 export * from './video-imports.js'

+ 34 - 0
server/server/middlewares/validators/videos/video-chapters.ts

@@ -0,0 +1,34 @@
+import express from 'express'
+import { body } from 'express-validator'
+import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
+import {
+  areValidationErrors, checkUserCanManageVideo, doesVideoExist,
+  isValidVideoIdParam
+} from '../shared/index.js'
+import { areVideoChaptersValid } from '@server/helpers/custom-validators/video-chapters.js'
+
+export const updateVideoChaptersValidator = [
+  isValidVideoIdParam('videoId'),
+
+  body('chapters')
+    .custom(areVideoChaptersValid)
+    .withMessage('Chapters must have a valid title and timecode, and each timecode must be unique'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+    if (!await doesVideoExist(req.params.videoId, res)) return
+
+    if (res.locals.videoAll.isLive) {
+      return res.fail({
+        status: HttpStatusCode.BAD_REQUEST_400,
+        message: 'You cannot add chapters to a live video'
+      })
+    }
+
+    // Check if the user who did the request is able to update video chapters (same right as updating the video)
+    const user = res.locals.oauth.token.User
+    if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
+
+    return next()
+  }
+]

+ 2 - 0
server/server/models/video/formatter/video-activity-pub-format.ts

@@ -13,6 +13,7 @@ import {
 } from '@peertube/peertube-models'
 import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js'
 import {
+  getLocalVideoChaptersActivityPubUrl,
   getLocalVideoCommentsActivityPubUrl,
   getLocalVideoDislikesActivityPubUrl,
   getLocalVideoLikesActivityPubUrl,
@@ -95,6 +96,7 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
     dislikes: getLocalVideoDislikesActivityPubUrl(video),
     shares: getLocalVideoSharesActivityPubUrl(video),
     comments: getLocalVideoCommentsActivityPubUrl(video),
+    hasParts: getLocalVideoChaptersActivityPubUrl(video),
 
     attributedTo: [
       {

+ 95 - 0
server/server/models/video/video-chapter.ts

@@ -0,0 +1,95 @@
+import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
+import { MVideo, MVideoChapter } from '@server/types/models/index.js'
+import { VideoChapter, VideoChapterObject } from '@peertube/peertube-models'
+import { AttributesOnly } from '@peertube/peertube-typescript-utils'
+import { VideoModel } from './video.js'
+import { Transaction } from 'sequelize'
+import { getSort } from '../shared/sort.js'
+
+@Table({
+  tableName: 'videoChapter',
+  indexes: [
+    {
+      fields: [ 'videoId', 'timecode' ],
+      unique: true
+    }
+  ]
+})
+export class VideoChapterModel extends Model<Partial<AttributesOnly<VideoChapterModel>>> {
+
+  @AllowNull(false)
+  @Column
+  timecode: number
+
+  @AllowNull(false)
+  @Column
+  title: string
+
+  @ForeignKey(() => VideoModel)
+  @Column
+  videoId: number
+
+  @BelongsTo(() => VideoModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  Video: Awaited<VideoModel>
+
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  static deleteChapters (videoId: number, transaction: Transaction) {
+    const query = {
+      where: {
+        videoId
+      },
+      transaction
+    }
+
+    return VideoChapterModel.destroy(query)
+  }
+
+  static listChaptersOfVideo (videoId: number, transaction?: Transaction) {
+    const query = {
+      where: {
+        videoId
+      },
+      order: getSort('timecode'),
+      transaction
+    }
+
+    return VideoChapterModel.findAll<MVideoChapter>(query)
+  }
+
+  static hasVideoChapters (videoId: number, transaction: Transaction) {
+    return VideoChapterModel.findOne({
+      where: { videoId },
+      transaction
+    }).then(c => !!c)
+  }
+
+  toActivityPubJSON (this: MVideoChapter, options: {
+    video: MVideo
+    nextChapter: MVideoChapter
+  }): VideoChapterObject {
+    return {
+      name: this.title,
+      startOffset: this.timecode,
+      endOffset: options.nextChapter
+        ? options.nextChapter.timecode
+        : options.video.duration
+    }
+  }
+
+  toFormattedJSON (this: MVideoChapter): VideoChapter {
+    return {
+      timecode: this.timecode,
+      title: this.title
+    }
+  }
+}

+ 1 - 1
server/server/types/models/account/account.ts

@@ -14,7 +14,7 @@ import {
   MActorSummaryFormattable,
   MActorUrl
 } from '../actor/index.js'
-import { MChannelDefault } from '../video/video-channels.js'
+import { MChannelDefault } from '../video/video-channel.js'
 import { MAccountBlocklistId } from './account-blocklist.js'
 
 type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M>

+ 1 - 1
server/server/types/models/user/user.ts

@@ -11,7 +11,7 @@ import {
   MAccountIdActorId,
   MAccountUrl
 } from '../account/index.js'
-import { MChannelFormattable } from '../video/video-channels.js'
+import { MChannelFormattable } from '../video/video-channel.js'
 import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting.js'
 
 type Use<K extends keyof UserModel, M> = PickWith<UserModel, K, M>

+ 2 - 1
server/server/types/models/video/index.ts

@@ -10,7 +10,8 @@ export * from './video-blacklist.js'
 export * from './video-caption.js'
 export * from './video-change-ownership.js'
 export * from './video-channel-sync.js'
-export * from './video-channels.js'
+export * from './video-channel.js'
+export * from './video-chapter.js'
 export * from './video-comment.js'
 export * from './video-file.js'
 export * from './video-import.js'

+ 1 - 1
server/server/types/models/video/video-channel-sync.ts

@@ -1,6 +1,6 @@
 import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
 import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils'
-import { MChannelAccountDefault, MChannelFormattable } from './video-channels.js'
+import { MChannelAccountDefault, MChannelFormattable } from './video-channel.js'
 
 type Use<K extends keyof VideoChannelSyncModel, M> = PickWith<VideoChannelSyncModel, K, M>
 

+ 0 - 0
server/server/types/models/video/video-channels.ts → server/server/types/models/video/video-channel.ts


+ 3 - 0
server/server/types/models/video/video-chapter.ts

@@ -0,0 +1,3 @@
+import { VideoChapterModel } from '@server/models/video/video-chapter.js'
+
+export type MVideoChapter = Omit<VideoChapterModel, 'Video'>

+ 1 - 1
server/server/types/models/video/video-playlist.ts

@@ -3,7 +3,7 @@ import { PickWith } from '@peertube/peertube-typescript-utils'
 import { VideoPlaylistModel } from '../../../models/video/video-playlist.js'
 import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account/index.js'
 import { MThumbnail } from './thumbnail.js'
-import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channels.js'
+import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channel.js'
 
 type Use<K extends keyof VideoPlaylistModel, M> = PickWith<VideoPlaylistModel, K, M>
 

+ 1 - 1
server/server/types/models/video/video.ts

@@ -16,7 +16,7 @@ import {
   MChannelFormattable,
   MChannelHostOnly,
   MChannelUserId
-} from './video-channels.js'
+} from './video-channel.js'
 import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js'
 import { MVideoLive } from './video-live.js'
 import {

Some files were not shown because too many files changed in this diff