From 77b70702d2193d78bf6fbd07f0fc7335e34957f8 Mon Sep 17 00:00:00 2001
From: Chocobozzz <me@florianbigard.com>
Date: Mon, 28 Aug 2023 10:55:04 +0200
Subject: [PATCH] Add video chapters support

---
 .../shared/video-edit.component.html          |  52 ++-
 .../shared/video-edit.component.scss          |  26 ++
 .../shared/video-edit.component.ts            |  98 ++++-
 .../video-go-live.component.ts                |   7 +-
 .../video-import-torrent.component.ts         |  30 +-
 .../video-import-url.component.html           |   1 +
 .../video-import-url.component.ts             |  19 +-
 .../video-add-components/video-send.ts        |  28 +-
 .../video-upload.component.ts                 |   7 +-
 .../+video-edit/video-update.component.html   |   1 +
 .../+video-edit/video-update.component.ts     |  33 +-
 .../+video-edit/video-update.resolver.ts      |  13 +-
 .../+video-watch/video-watch.component.ts     |  16 +-
 client/src/app/helpers/utils/object.ts        |  12 -
 .../app/menu/language-chooser.component.ts    |   4 +-
 .../video-chapter-validators.ts               |  32 ++
 .../form-validators/video-validators.ts       |   8 -
 .../shared-forms/form-reactive.service.ts     |   6 +-
 .../shared-forms/form-validator.service.ts    |  59 ++-
 .../shared-forms/input-text.component.ts      |   6 +-
 .../markdown-textarea.component.html          |   2 +-
 .../markdown-textarea.component.ts            |   3 +-
 .../timestamp-input.component.scss            |   1 +
 .../shared-main/buttons/button.component.html |   2 +-
 .../shared/shared-main/shared-main.module.ts  |   3 +
 .../video-caption/video-caption.service.ts    |   4 +-
 .../src/app/shared/shared-main/video/index.ts |   2 +
 .../video/video-chapter.service.ts            |  34 ++
 .../video/video-chapters-edit.model.ts        |  43 +++
 client/src/assets/player/peertube-player.ts   |   7 +
 .../shared/control-bar/chapters-plugin.ts     |  64 ++++
 .../assets/player/shared/control-bar/index.ts |   2 +
 .../progress-bar-marker-component.ts          |  24 ++
 .../shared/control-bar/storyboard-plugin.ts   |   4 +-
 .../player/shared/control-bar/time-tooltip.ts |  20 +
 .../player/types/peertube-player-options.ts   |   3 +-
 .../player/types/peertube-videojs-typings.ts  |  15 +-
 client/src/sass/player/control-bar.scss       |  19 +
 client/src/standalone/videos/embed.ts         |   9 +-
 .../videos/shared/player-options-builder.ts   |  18 +-
 .../standalone/videos/shared/video-fetcher.ts |   7 +-
 packages/core-utils/src/common/array.ts       |  22 +-
 packages/core-utils/src/common/date.ts        |   4 +-
 packages/core-utils/src/index.ts              |   1 +
 packages/core-utils/src/string/chapters.ts    |  32 ++
 packages/core-utils/src/string/index.ts       |   1 +
 packages/ffmpeg/src/ffprobe.ts                |  19 +-
 packages/models/src/activitypub/context.ts    |   3 +-
 .../models/src/activitypub/objects/index.ts   |   1 +
 .../objects/video-chapters-object.ts          |  11 +
 .../src/activitypub/objects/video-object.ts   |   1 +
 .../videos/chapter/chapter-update.model.ts    |   6 +
 .../src/videos/chapter/chapter.model.ts       |   4 +
 packages/models/src/videos/chapter/index.ts   |   2 +
 packages/models/src/videos/index.ts           |   1 +
 packages/server-commands/src/server/server.ts |   3 +
 .../src/videos/chapters-command.ts            |  38 ++
 packages/server-commands/src/videos/index.ts  |   1 +
 packages/tests/fixtures/video_chapters.mp4    | Bin 0 -> 39611 bytes
 packages/tests/src/api/check-params/index.ts  |   1 +
 .../src/api/check-params/video-captions.ts    |  23 +-
 .../src/api/check-params/video-chapters.ts    | 172 +++++++++
 packages/tests/src/api/videos/index.ts        |   1 +
 .../tests/src/api/videos/video-chapters.ts    | 342 ++++++++++++++++++
 .../tests/src/server-helpers/core-utils.ts    |  27 +-
 packages/tests/src/shared/tests.ts            |   3 +
 packages/typescript-utils/src/types.ts        |   2 +
 .../server/controllers/activitypub/client.ts  |  65 +++-
 .../server/controllers/api/videos/chapters.ts |  51 +++
 server/server/controllers/api/videos/index.ts |   2 +
 .../server/controllers/api/videos/update.ts   |  11 +
 .../server/controllers/api/videos/upload.ts   |   9 +
 server/server/helpers/activity-pub-utils.ts   |  11 +-
 .../activitypub/video-chapters.ts             |  15 +
 .../custom-validators/video-chapters.ts       |  26 ++
 .../youtube-dl/youtube-dl-info-builder.ts     |  11 +-
 server/server/initializers/constants.ts       |   3 +
 server/server/initializers/database.ts        |   2 +
 server/server/lib/activitypub/url.ts          |   5 +
 .../videos/shared/abstract-builder.ts         |  36 +-
 .../lib/activitypub/videos/shared/creator.ts  |   2 +
 .../server/lib/activitypub/videos/updater.ts  |   2 +
 server/server/lib/internal-event-emitter.ts   |   4 +-
 .../lib/job-queue/handlers/video-import.ts    |   6 +
 server/server/lib/video-chapters.ts           |  99 +++++
 server/server/lib/video-pre-import.ts         |  24 ++
 server/server/middlewares/cache/cache.ts      |  36 +-
 server/server/middlewares/validators/feeds.ts |  11 -
 .../middlewares/validators/videos/index.ts    |   1 +
 .../validators/videos/video-chapters.ts       |  34 ++
 .../formatter/video-activity-pub-format.ts    |   2 +
 server/server/models/video/video-chapter.ts   |  95 +++++
 server/server/types/models/account/account.ts |   2 +-
 server/server/types/models/user/user.ts       |   2 +-
 server/server/types/models/video/index.ts     |   3 +-
 .../types/models/video/video-channel-sync.ts  |   2 +-
 .../{video-channels.ts => video-channel.ts}   |   0
 .../types/models/video/video-chapter.ts       |   3 +
 .../types/models/video/video-playlist.ts      |   2 +-
 server/server/types/models/video/video.ts     |   2 +-
 support/doc/api/openapi.yaml                  |  71 +++-
 101 files changed, 1957 insertions(+), 158 deletions(-)
 create mode 100644 client/src/app/shared/form-validators/video-chapter-validators.ts
 create mode 100644 client/src/app/shared/shared-main/video/video-chapter.service.ts
 create mode 100644 client/src/app/shared/shared-main/video/video-chapters-edit.model.ts
 create mode 100644 client/src/assets/player/shared/control-bar/chapters-plugin.ts
 create mode 100644 client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts
 create mode 100644 client/src/assets/player/shared/control-bar/time-tooltip.ts
 create mode 100644 packages/core-utils/src/string/chapters.ts
 create mode 100644 packages/core-utils/src/string/index.ts
 create mode 100644 packages/models/src/activitypub/objects/video-chapters-object.ts
 create mode 100644 packages/models/src/videos/chapter/chapter-update.model.ts
 create mode 100644 packages/models/src/videos/chapter/chapter.model.ts
 create mode 100644 packages/models/src/videos/chapter/index.ts
 create mode 100644 packages/server-commands/src/videos/chapters-command.ts
 create mode 100644 packages/tests/fixtures/video_chapters.mp4
 create mode 100644 packages/tests/src/api/check-params/video-chapters.ts
 create mode 100644 packages/tests/src/api/videos/video-chapters.ts
 create mode 100644 server/server/controllers/api/videos/chapters.ts
 create mode 100644 server/server/helpers/custom-validators/activitypub/video-chapters.ts
 create mode 100644 server/server/helpers/custom-validators/video-chapters.ts
 create mode 100644 server/server/lib/video-chapters.ts
 create mode 100644 server/server/middlewares/validators/videos/video-chapters.ts
 create mode 100644 server/server/models/video/video-chapter.ts
 rename server/server/types/models/video/{video-channels.ts => video-channel.ts} (100%)
 create mode 100644 server/server/types/models/video/video-chapter.ts

diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
index f3c1f1634..8342562c3 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
@@ -230,6 +230,57 @@
       </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>
 
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss
index b0c053019..a81d62dd1 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss
@@ -117,6 +117,32 @@ p-calendar {
   @include orange-button;
 }
 
+.hide-chapter-label {
+  height: 0;
+  opacity: 0;
+}
+
+.chapter {
+  display: grid;
+  grid-template-columns: auto auto minmax(150px, 350px) 1fr;
+  grid-template-rows: auto auto;
+  column-gap: 1rem;
+
+  .position {
+    height: 31px;
+    display: flex;
+    align-items: center;
+  }
+
+  my-delete-button {
+    width: fit-content;
+  }
+
+  .form-error {
+    margin-top: 0;
+  }
+}
+
 @include on-small-main-col {
   .form-columns {
     grid-template-columns: 1fr;
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
index 898d3b0a6..35beba5b1 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
@@ -2,10 +2,10 @@ import { forkJoin } from 'rxjs'
 import { map } from 'rxjs/operators'
 import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model'
 import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
-import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms'
+import { AbstractControl, FormArray, FormGroup, Validators } from '@angular/forms'
 import { HooksService, PluginService, ServerService } from '@app/core'
 import { removeElementFromArray } from '@app/helpers'
-import { BuildFormValidator } from '@app/shared/form-validators'
+import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators'
 import {
   VIDEO_CATEGORY_VALIDATOR,
   VIDEO_CHANNEL_VALIDATOR,
@@ -20,9 +20,10 @@ import {
   VIDEO_SUPPORT_VALIDATOR,
   VIDEO_TAGS_ARRAY_VALIDATOR
 } from '@app/shared/form-validators/video-validators'
-import { FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms'
+import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators'
+import { FormReactiveErrors, FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms'
 import { InstanceService } from '@app/shared/shared-instance'
-import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoChaptersEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import {
   HTMLServerConfig,
@@ -30,6 +31,7 @@ import {
   LiveVideoLatencyMode,
   RegisterClientFormFieldOptions,
   RegisterClientVideoFieldOptions,
+  VideoChapter,
   VideoConstant,
   VideoDetails,
   VideoPrivacy,
@@ -57,7 +59,7 @@ type PluginField = {
 })
 export class VideoEditComponent implements OnInit, OnDestroy {
   @Input() form: FormGroup
-  @Input() formErrors: { [ id: string ]: string } = {}
+  @Input() formErrors: FormReactiveErrors & { chapters?: { title: string }[] } = {}
   @Input() validationMessages: FormReactiveValidationMessages = {}
 
   @Input() videoToUpdate: VideoDetails
@@ -68,6 +70,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
   @Input() videoCaptions: VideoCaptionWithPathEdit[] = []
   @Input() videoSource: VideoSource
 
+  @Input() videoChapters: VideoChapter[] = []
+
   @Input() hideWaitTranscoding = false
   @Input() updateVideoFileEnabled = false
 
@@ -150,7 +154,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
       licence: this.serverConfig.defaults.publish.licence,
       tags: []
     }
-    const obj: { [ id: string ]: BuildFormValidator } = {
+    const obj: BuildFormArgument = {
       name: VIDEO_NAME_VALIDATOR,
       privacy: VIDEO_PRIVACY_VALIDATOR,
       videoPassword: VIDEO_PASSWORD_VALIDATOR,
@@ -183,12 +187,16 @@ export class VideoEditComponent implements OnInit, OnDestroy {
       defaultValues
     )
 
-    this.form.addControl('captions', new FormArray([
-      new FormGroup({
-        language: new FormControl(),
-        captionfile: new FormControl()
-      })
-    ]))
+    this.form.addControl('chapters', new FormArray([], VIDEO_CHAPTERS_ARRAY_VALIDATOR.VALIDATORS))
+    this.addNewChapterControl()
+
+    this.form.get('chapters').valueChanges.subscribe((chapters: { title: string, timecode: string }[]) => {
+      const lastChapter = chapters[chapters.length - 1]
+
+      if (lastChapter.title || lastChapter.timecode) {
+        this.addNewChapterControl()
+      }
+    })
 
     this.trackChannelChange()
     this.trackPrivacyChange()
@@ -426,6 +434,70 @@ export class VideoEditComponent implements OnInit, OnDestroy {
     this.form.valueChanges.subscribe(() => this.formValidatorService.updateTreeValidity(this.pluginDataFormGroup))
   }
 
+  // ---------------------------------------------------------------------------
+
+  addNewChapterControl () {
+    const chaptersFormArray = this.getChaptersFormArray()
+    const controls = chaptersFormArray.controls
+
+    if (controls.length !== 0) {
+      const lastControl = chaptersFormArray.controls[controls.length - 1]
+      lastControl.get('title').addValidators(Validators.required)
+    }
+
+    this.formValidatorService.addControlInFormArray({
+      controlName: 'chapters',
+      formArray: chaptersFormArray,
+      formErrors: this.formErrors,
+      validationMessages: this.validationMessages,
+      formToBuild: {
+        timecode: null,
+        title: VIDEO_CHAPTER_TITLE_VALIDATOR
+      },
+      defaultValues: {
+        timecode: 0
+      }
+    })
+  }
+
+  getChaptersFormArray () {
+    return this.form.controls['chapters'] as FormArray
+  }
+
+  deleteChapterControl (index: number) {
+    this.formValidatorService.removeControlFromFormArray({
+      controlName: 'chapters',
+      formArray: this.getChaptersFormArray(),
+      formErrors: this.formErrors,
+      validationMessages: this.validationMessages,
+      index
+    })
+  }
+
+  isLastChapterControl (index: number) {
+    return this.getChaptersFormArray().length - 1 === index
+  }
+
+  patchChapters (chaptersEdit: VideoChaptersEdit) {
+    const totalChapters = chaptersEdit.getChaptersForUpdate().length
+    const totalControls = this.getChaptersFormArray().length
+
+    // Add missing controls. We use <= because we need the "empty control" to add another chapter
+    for (let i = 0; i <= totalChapters - totalControls; i++) {
+      this.addNewChapterControl()
+    }
+
+    this.form.patchValue(chaptersEdit.toFormPatch())
+  }
+
+  getChapterArrayErrors () {
+    if (!this.getChaptersFormArray().errors) return ''
+
+    return Object.values(this.getChaptersFormArray().errors).join('. ')
+  }
+
+  // ---------------------------------------------------------------------------
+
   private trackPrivacyChange () {
     // We will update the schedule input and the wait transcoding checkbox validators
     this.form.controls['privacy']
@@ -469,8 +541,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
           } else {
             videoPasswordControl.clearValidators()
           }
-          videoPasswordControl.updateValueAndValidity()
 
+          videoPasswordControl.updateValueAndValidity()
         }
       )
   }
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
index f7a570ed3..69d12b85f 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
@@ -4,7 +4,7 @@ import { Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
 import { scrollToTop } from '@app/helpers'
 import { FormReactiveService } from '@app/shared/shared-forms'
-import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { LiveVideoService } from '@app/shared/shared-video-live'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { logger } from '@root-helpers/logger'
@@ -54,6 +54,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
     protected serverService: ServerService,
     protected videoService: VideoService,
     protected videoCaptionService: VideoCaptionService,
+    protected videoChapterService: VideoChapterService,
     private liveVideoService: LiveVideoService,
     private router: Router,
     private hooks: HooksService
@@ -137,6 +138,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
     video.uuid = this.videoUUID
     video.shortUUID = this.videoShortUUID
 
+    this.chaptersEdit.patch(this.form.value)
+
     const saveReplay = this.form.value.saveReplay
     const replaySettings = saveReplay
       ? { privacy: this.form.value.replayPrivacy }
@@ -151,7 +154,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
 
     // Update the video
     forkJoin([
-      this.updateVideoAndCaptions(video),
+      this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions }),
 
       this.liveVideoService.updateLive(this.videoId, liveVideoUpdate)
     ]).subscribe({
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts
index 97517e1c7..50eb14c6e 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts
@@ -4,7 +4,7 @@ import { Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
 import { scrollToTop } from '@app/helpers'
 import { FormReactiveService } from '@app/shared/shared-forms'
-import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
+import { VideoCaptionService, VideoChapterService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { logger } from '@root-helpers/logger'
 import { PeerTubeProblemDocument, ServerErrorCode, VideoUpdate } from '@peertube/peertube-models'
@@ -42,6 +42,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af
     protected serverService: ServerService,
     protected videoService: VideoService,
     protected videoCaptionService: VideoCaptionService,
+    protected videoChapterService: VideoChapterService,
     private router: Router,
     private videoImportService: VideoImportService,
     private hooks: HooksService
@@ -124,24 +125,25 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af
     if (!await this.isFormValid()) return
 
     this.video.patch(this.form.value)
+    this.chaptersEdit.patch(this.form.value)
 
     this.isUpdatingVideo = true
 
     // Update the video
-    this.updateVideoAndCaptions(this.video)
-        .subscribe({
-          next: () => {
-            this.isUpdatingVideo = false
-            this.notifier.success($localize`Video to import updated.`)
+    this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit })
+      .subscribe({
+        next: () => {
+          this.isUpdatingVideo = false
+          this.notifier.success($localize`Video to import updated.`)
 
-            this.router.navigate([ '/my-library', 'video-imports' ])
-          },
+          this.router.navigate([ '/my-library', 'video-imports' ])
+        },
 
-          error: err => {
-            this.error = err.message
-            scrollToTop()
-            logger.error(err)
-          }
-        })
+        error: err => {
+          this.error = err.message
+          scrollToTop()
+          logger.error(err)
+        }
+      })
   }
 }
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html
index a80d31aaf..30eeca704 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html
@@ -56,6 +56,7 @@
 <!-- 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"
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts
index 634bd9914..4dc04b83e 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts
@@ -1,16 +1,17 @@
 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
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-send.ts b/client/src/app/+videos/+video-edit/video-add-components/video-send.ts
index 56dcfa0e6..2c38e11a3 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-send.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-send.ts
@@ -4,9 +4,17 @@ import { Directive, EventEmitter, OnInit } from '@angular/core'
 import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core'
 import { listUserChannelsForSelect } from '@app/helpers'
 import { FormReactive } from '@app/shared/shared-forms'
-import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
+import {
+  VideoCaptionEdit,
+  VideoCaptionService,
+  VideoChapterService,
+  VideoChaptersEdit,
+  VideoEdit,
+  VideoService
+} from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models'
+import { of } from 'rxjs'
 
 @Directive()
 // eslint-disable-next-line @angular-eslint/directive-class-suffix
@@ -14,6 +22,7 @@ export abstract class VideoSend extends FormReactive implements OnInit {
   userVideoChannels: SelectChannelItem[] = []
   videoPrivacies: VideoConstant<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()
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
index cbf43ee5f..cc0dcc1ae 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
@@ -7,7 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
 import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
 import { FormReactiveService } from '@app/shared/shared-forms'
-import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { logger } from '@root-helpers/logger'
 import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models'
@@ -63,6 +63,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     protected serverService: ServerService,
     protected videoService: VideoService,
     protected videoCaptionService: VideoCaptionService,
+    protected videoChapterService: VideoChapterService,
     private userService: UserService,
     private router: Router,
     private hooks: HooksService,
@@ -241,9 +242,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     video.uuid = this.videoUploadedIds.uuid
     video.shortUUID = this.videoUploadedIds.shortUUID
 
+    this.chaptersEdit.patch(this.form.value)
+
     this.isUpdatingVideo = true
 
-    this.updateVideoAndCaptions(video)
+    this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions, chapters: this.chaptersEdit })
         .subscribe({
           next: () => {
             this.isUpdatingVideo = false
diff --git a/client/src/app/+videos/+video-edit/video-update.component.html b/client/src/app/+videos/+video-edit/video-update.component.html
index 9a99c0c3d..2f667658c 100644
--- a/client/src/app/+videos/+video-edit/video-update.component.html
+++ b/client/src/app/+videos/+video-edit/video-update.component.html
@@ -13,6 +13,7 @@
   <form novalidate [formGroup]="form">
 
     <my-video-edit
+      #videoEdit
       [form]="form" [formErrors]="formErrors" [forbidScheduledPublication]="forbidScheduledPublication"
       [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
       [videoCaptions]="videoCaptions" [hideWaitTranscoding]="isWaitTranscodingHidden()"
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts
index ea2f76d71..82f45f73d 100644
--- a/client/src/app/+videos/+video-edit/video-update.component.ts
+++ b/client/src/app/+videos/+video-edit/video-update.component.ts
@@ -4,18 +4,28 @@ import { of, Subject, Subscription } from 'rxjs'
 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)
 
diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts
index d114bfb2d..0293f3c71 100644
--- a/client/src/app/+videos/+video-edit/video-update.resolver.ts
+++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts
@@ -4,7 +4,7 @@ import { Injectable } from '@angular/core'
 import { ActivatedRouteSnapshot } from '@angular/router'
 import { AuthService } from '@app/core'
 import { listUserChannelsForSelect } from '@app/helpers'
-import { VideoCaptionService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main'
+import { VideoCaptionService, VideoChapterService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main'
 import { LiveVideoService } from '@app/shared/shared-video-live'
 import { VideoPrivacy } from '@peertube/peertube-models'
 
@@ -15,6 +15,7 @@ export class VideoUpdateResolver {
     private liveVideoService: LiveVideoService,
     private authService: AuthService,
     private videoCaptionService: VideoCaptionService,
+    private videoChapterService: VideoChapterService,
     private videoPasswordService: VideoPasswordService
   ) {
   }
@@ -25,8 +26,8 @@ export class VideoUpdateResolver {
     return this.videoService.getVideo({ videoId: uuid })
                 .pipe(
                   switchMap(video => forkJoin(this.buildVideoObservables(video))),
-                  map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) =>
-                    ({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword }))
+                  map(([ video, videoSource, videoChannels, videoCaptions, videoChapters, liveVideo, videoPassword ]) =>
+                    ({ video, videoChannels, videoCaptions, videoChapters, videoSource, liveVideo, videoPassword }))
                 )
   }
 
@@ -46,6 +47,12 @@ export class VideoUpdateResolver {
           map(result => result.data)
         ),
 
+      this.videoChapterService
+        .getChapters({ videoId: video.uuid })
+        .pipe(
+          map(({ chapters }) => chapters)
+        ),
+
       video.isLive
         ? this.liveVideoService.getVideoLive(video.id)
         : of(undefined),
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
index febb3c828..39c9c7986 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -18,7 +18,7 @@ import {
 } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
 import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers'
-import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main'
+import { Video, VideoCaptionService, VideoChapterService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main'
 import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
 import { LiveVideoService } from '@app/shared/shared-video-live'
 import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
@@ -31,6 +31,7 @@ import {
   ServerErrorCode,
   Storyboard,
   VideoCaption,
+  VideoChapter,
   VideoPrivacy,
   VideoState,
   VideoStateType
@@ -83,6 +84,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
 
   video: VideoDetails = null
   videoCaptions: VideoCaption[] = []
+  videoChapters: VideoChapter[] = []
   liveVideo: LiveVideo
   videoPassword: string
   storyboards: Storyboard[] = []
@@ -125,6 +127,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     private notifier: Notifier,
     private zone: NgZone,
     private videoCaptionService: VideoCaptionService,
+    private videoChapterService: VideoChapterService,
     private hotkeysService: HotkeysService,
     private hooks: HooksService,
     private pluginService: PluginService,
@@ -306,14 +309,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     forkJoin([
       videoAndLiveObs,
       this.videoCaptionService.listCaptions(videoId, videoPassword),
+      this.videoChapterService.getChapters({ videoId, videoPassword }),
       this.videoService.getStoryboards(videoId, videoPassword),
       this.userService.getAnonymousOrLoggedUser()
     ]).subscribe({
-      next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => {
+      next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, loggedInOrAnonymousUser ]) => {
         this.onVideoFetched({
           video,
           live,
           videoCaptions: captionsResult.data,
+          videoChapters: chaptersResult.chapters,
           storyboards,
           videoFileToken,
           videoPassword,
@@ -411,6 +416,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     video: VideoDetails
     live: LiveVideo
     videoCaptions: VideoCaption[]
+    videoChapters: VideoChapter[]
     storyboards: Storyboard[]
     videoFileToken: string
     videoPassword: string
@@ -422,6 +428,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
       video,
       live,
       videoCaptions,
+      videoChapters,
       storyboards,
       videoFileToken,
       videoPassword,
@@ -433,6 +440,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
 
     this.video = video
     this.videoCaptions = videoCaptions
+    this.videoChapters = videoChapters
     this.liveVideo = live
     this.videoFileToken = videoFileToken
     this.videoPassword = videoPassword
@@ -480,6 +488,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     const params = {
       video: this.video,
       videoCaptions: this.videoCaptions,
+      videoChapters: this.videoChapters,
       storyboards: this.storyboards,
       liveVideo: this.liveVideo,
       videoFileToken: this.videoFileToken,
@@ -636,6 +645,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     video: VideoDetails
     liveVideo: LiveVideo
     videoCaptions: VideoCaption[]
+    videoChapters: VideoChapter[]
     storyboards: Storyboard[]
 
     videoFileToken: string
@@ -651,6 +661,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
       video,
       liveVideo,
       videoCaptions,
+      videoChapters,
       storyboards,
       videoFileToken,
       videoPassword,
@@ -750,6 +761,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
       videoPassword: () => videoPassword,
 
       videoCaptions: playerCaptions,
+      videoChapters,
       storyboard,
 
       videoShortUUID: video.shortUUID,
diff --git a/client/src/app/helpers/utils/object.ts b/client/src/app/helpers/utils/object.ts
index b69e31edf..ef1acd298 100644
--- a/client/src/app/helpers/utils/object.ts
+++ b/client/src/app/helpers/utils/object.ts
@@ -7,17 +7,6 @@ function removeElementFromArray <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,
diff --git a/client/src/app/menu/language-chooser.component.ts b/client/src/app/menu/language-chooser.component.ts
index 1ec5987c2..978a4af39 100644
--- a/client/src/app/menu/language-chooser.component.ts
+++ b/client/src/app/menu/language-chooser.component.ts
@@ -1,7 +1,7 @@
 import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
-import { getDevLocale, isOnDevLocale, sortBy } from '@app/helpers'
+import { getDevLocale, isOnDevLocale } from '@app/helpers'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped } from '@peertube/peertube-core-utils'
+import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped, sortBy } from '@peertube/peertube-core-utils'
 
 @Component({
   selector: 'my-language-chooser',
diff --git a/client/src/app/shared/form-validators/video-chapter-validators.ts b/client/src/app/shared/form-validators/video-chapter-validators.ts
new file mode 100644
index 000000000..cbbd9291e
--- /dev/null
+++ b/client/src/app/shared/form-validators/video-chapter-validators.ts
@@ -0,0 +1,32 @@
+import { AbstractControl, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'
+import { BuildFormValidator } from './form-validator.model'
+
+export const VIDEO_CHAPTER_TITLE_VALIDATOR: BuildFormValidator = {
+  VALIDATORS: [ Validators.minLength(2), Validators.maxLength(100) ], // Required is set dynamically
+  MESSAGES: {
+    required: $localize`A chapter title is required.`,
+    minlength: $localize`A chapter title should be more than 2 characters long.`,
+    maxlength: $localize`A chapter title should be less than 100 characters long.`
+  }
+}
+
+export const VIDEO_CHAPTERS_ARRAY_VALIDATOR: BuildFormValidator = {
+  VALIDATORS: [ uniqueTimecodeValidator() ],
+  MESSAGES: {}
+}
+
+function uniqueTimecodeValidator (): ValidatorFn {
+  return (control: AbstractControl): ValidationErrors => {
+    const array = control.value as { timecode: number, title: string }[]
+
+    for (const chapter of array) {
+      if (!chapter.title) continue
+
+      if (array.filter(c => c.title && c.timecode === chapter.timecode).length > 1) {
+        return { uniqueTimecode: $localize`Multiple chapters have the same timecode ${chapter.timecode}` }
+      }
+    }
+
+    return null
+  }
+}
diff --git a/client/src/app/shared/form-validators/video-validators.ts b/client/src/app/shared/form-validators/video-validators.ts
index 090a76e43..a434c777f 100644
--- a/client/src/app/shared/form-validators/video-validators.ts
+++ b/client/src/app/shared/form-validators/video-validators.ts
@@ -70,14 +70,6 @@ export const VIDEO_DESCRIPTION_VALIDATOR: BuildFormValidator = {
   }
 }
 
-export const VIDEO_TAG_VALIDATOR: BuildFormValidator = {
-  VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ],
-  MESSAGES: {
-    minlength: $localize`A tag should be more than 2 characters long.`,
-    maxlength: $localize`A tag should be less than 30 characters long.`
-  }
-}
-
 export const VIDEO_TAGS_ARRAY_VALIDATOR: BuildFormValidator = {
   VALIDATORS: [ Validators.maxLength(5), arrayTagLengthValidator() ],
   MESSAGES: {
diff --git a/client/src/app/shared/shared-forms/form-reactive.service.ts b/client/src/app/shared/shared-forms/form-reactive.service.ts
index f1b7e0ef2..b960c310e 100644
--- a/client/src/app/shared/shared-forms/form-reactive.service.ts
+++ b/client/src/app/shared/shared-forms/form-reactive.service.ts
@@ -4,9 +4,9 @@ import { wait } from '@root-helpers/utils'
 import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
 import { FormValidatorService } from './form-validator.service'
 
-export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
+export type FormReactiveErrors = { [ id: string | number ]: string | FormReactiveErrors | FormReactiveErrors[] }
 export type FormReactiveValidationMessages = {
-  [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
+  [ id: string | number ]: { [ name: string ]: string } | FormReactiveValidationMessages | FormReactiveValidationMessages[]
 }
 
 @Injectable()
@@ -86,7 +86,7 @@ export class FormReactiveService {
 
       if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue
 
-      const staticMessages = validationMessages[field]
+      const staticMessages = validationMessages[field] as FormReactiveValidationMessages
       for (const key of Object.keys(control.errors)) {
         const formErrorValue = control.errors[key]
 
diff --git a/client/src/app/shared/shared-forms/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validator.service.ts
index e7dedf52a..d810285bb 100644
--- a/client/src/app/shared/shared-forms/form-validator.service.ts
+++ b/client/src/app/shared/shared-forms/form-validator.service.ts
@@ -45,20 +45,20 @@ export class FormValidatorService {
     form: FormGroup,
     formErrors: FormReactiveErrors,
     validationMessages: FormReactiveValidationMessages,
-    obj: BuildFormArgument,
+    formToBuild: BuildFormArgument,
     defaultValues: BuildFormDefaultValues = {}
   ) {
-    for (const name of objectKeysTyped(obj)) {
+    for (const name of objectKeysTyped(formToBuild)) {
       formErrors[name] = ''
 
-      const field = obj[name]
+      const field = formToBuild[name]
       if (this.isRecursiveField(field)) {
         this.updateFormGroup(
           // FIXME: typings
           (form as any)[name],
           formErrors[name] as FormReactiveErrors,
           validationMessages[name] as FormReactiveValidationMessages,
-          obj[name] as BuildFormArgument,
+          formToBuild[name] as BuildFormArgument,
           defaultValues[name] as BuildFormDefaultValues
         )
         continue
@@ -66,7 +66,7 @@ export class FormValidatorService {
 
       if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
 
-      const defaultValue = defaultValues[name] || ''
+      const defaultValue = defaultValues[name] ?? ''
 
       form.addControl(
         name + '',
@@ -75,6 +75,55 @@ export class FormValidatorService {
     }
   }
 
+  addControlInFormArray (options: {
+    formErrors: FormReactiveErrors
+    validationMessages: FormReactiveValidationMessages
+    formArray: FormArray
+    controlName: string
+    formToBuild: BuildFormArgument
+    defaultValues?: BuildFormDefaultValues
+  }) {
+    const { formArray, formErrors, validationMessages, controlName, formToBuild, defaultValues = {} } = options
+
+    const formGroup = new FormGroup({})
+    if (!formErrors[controlName]) formErrors[controlName] = [] as FormReactiveErrors[]
+    if (!validationMessages[controlName]) validationMessages[controlName] = []
+
+    const formArrayErrors = formErrors[controlName] as FormReactiveErrors[]
+    const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[]
+
+    const totalControls = formArray.controls.length
+    formArrayErrors.push({})
+    formArrayValidationMessages.push({})
+
+    this.updateFormGroup(
+      formGroup,
+      formArrayErrors[totalControls],
+      formArrayValidationMessages[totalControls],
+      formToBuild,
+      defaultValues
+    )
+
+    formArray.push(formGroup)
+  }
+
+  removeControlFromFormArray (options: {
+    formErrors: FormReactiveErrors
+    validationMessages: FormReactiveValidationMessages
+    index: number
+    formArray: FormArray
+    controlName: string
+  }) {
+    const { formArray, formErrors, validationMessages, index, controlName } = options
+
+    const formArrayErrors = formErrors[controlName] as FormReactiveErrors[]
+    const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[]
+
+    formArrayErrors.splice(index, 1)
+    formArrayValidationMessages.splice(index, 1)
+    formArray.removeAt(index)
+  }
+
   updateTreeValidity (group: FormGroup | FormArray): void {
     for (const key of Object.keys(group.controls)) {
       // FIXME: typings
diff --git a/client/src/app/shared/shared-forms/input-text.component.ts b/client/src/app/shared/shared-forms/input-text.component.ts
index be03f25b9..2f3c8f603 100644
--- a/client/src/app/shared/shared-forms/input-text.component.ts
+++ b/client/src/app/shared/shared-forms/input-text.component.ts
@@ -1,6 +1,6 @@
 import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core'
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
-import { Notifier } from '@app/core'
+import { FormReactiveErrors } from './form-reactive.service'
 
 @Component({
   selector: 'my-input-text',
@@ -26,9 +26,7 @@ export class InputTextComponent implements ControlValueAccessor {
   @Input() withCopy = false
   @Input() readonly = false
   @Input() show = false
-  @Input() formError: string
-
-  constructor (private notifier: Notifier) { }
+  @Input() formError: string | FormReactiveErrors | FormReactiveErrors[]
 
   get inputType () {
     return this.show
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.html b/client/src/app/shared/shared-forms/markdown-textarea.component.html
index ac2dfd17c..7f8bd2f62 100644
--- a/client/src/app/shared/shared-forms/markdown-textarea.component.html
+++ b/client/src/app/shared/shared-forms/markdown-textarea.component.html
@@ -25,7 +25,7 @@
       </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>
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.ts b/client/src/app/shared/shared-forms/markdown-textarea.component.ts
index 169be39d1..77e6cbd8c 100644
--- a/client/src/app/shared/shared-forms/markdown-textarea.component.ts
+++ b/client/src/app/shared/shared-forms/markdown-textarea.component.ts
@@ -6,6 +6,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
 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
 
diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.scss b/client/src/app/shared/shared-forms/timestamp-input.component.scss
index e69a06947..df19240b4 100644
--- a/client/src/app/shared/shared-forms/timestamp-input.component.scss
+++ b/client/src/app/shared/shared-forms/timestamp-input.component.scss
@@ -4,6 +4,7 @@
 p-inputmask {
   ::ng-deep input {
     width: 80px;
+    text-align: center;
 
     &:focus-within,
     &:focus {
diff --git a/client/src/app/shared/shared-main/buttons/button.component.html b/client/src/app/shared/shared-main/buttons/button.component.html
index d87e35876..9270c0925 100644
--- a/client/src/app/shared/shared-main/buttons/button.component.html
+++ b/client/src/app/shared/shared-main/buttons/button.component.html
@@ -1,4 +1,4 @@
-<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>
 
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts
index 243394bda..30c6cabf5 100644
--- a/client/src/app/shared/shared-main/shared-main.module.ts
+++ b/client/src/app/shared/shared-main/shared-main.module.ts
@@ -49,6 +49,7 @@ import { UserHistoryService, UserNotificationsComponent, UserNotificationService
 import {
   EmbedComponent,
   RedundancyService,
+  VideoChapterService,
   VideoFileTokenService,
   VideoImportService,
   VideoOwnershipService,
@@ -215,6 +216,8 @@ import { VideoChannelService } from './video-channel'
 
     VideoPasswordService,
 
+    VideoChapterService,
+
     CustomPageService,
 
     ActorRedirectGuard
diff --git a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
index 59c0969a9..5e4a27d4e 100644
--- a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
+++ b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
@@ -3,9 +3,9 @@ import { catchError, map, switchMap } from 'rxjs/operators'
 import { HttpClient } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { RestExtractor, ServerService } from '@app/core'
-import { objectToFormData, sortBy } from '@app/helpers'
+import { objectToFormData } from '@app/helpers'
 import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video'
-import { peertubeTranslate } from '@peertube/peertube-core-utils'
+import { peertubeTranslate, sortBy } from '@peertube/peertube-core-utils'
 import { ResultList, VideoCaption } from '@peertube/peertube-models'
 import { environment } from '../../../../environments/environment'
 import { VideoCaptionEdit } from './video-caption-edit.model'
diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts
index 07d40b117..7414ded23 100644
--- a/client/src/app/shared/shared-main/video/index.ts
+++ b/client/src/app/shared/shared-main/video/index.ts
@@ -1,5 +1,7 @@
 export * from './embed.component'
 export * from './redundancy.service'
+export * from './video-chapter.service'
+export * from './video-chapters-edit.model'
 export * from './video-details.model'
 export * from './video-edit.model'
 export * from './video-file-token.service'
diff --git a/client/src/app/shared/shared-main/video/video-chapter.service.ts b/client/src/app/shared/shared-main/video/video-chapter.service.ts
new file mode 100644
index 000000000..6d221c9e9
--- /dev/null
+++ b/client/src/app/shared/shared-main/video/video-chapter.service.ts
@@ -0,0 +1,34 @@
+import { catchError } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor } from '@app/core'
+import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models'
+import { VideoPasswordService } from './video-password.service'
+import { VideoService } from './video.service'
+import { VideoChaptersEdit } from './video-chapters-edit.model'
+import { of } from 'rxjs'
+
+@Injectable()
+export class VideoChapterService {
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor
+  ) {}
+
+  getChapters (options: { videoId: string, videoPassword?: string }) {
+    const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword)
+
+    return this.authHttp.get<{ chapters: VideoChapter[] }>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}/chapters`, { headers })
+      .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  updateChapters (videoId: string, chaptersEdit: VideoChaptersEdit) {
+    if (chaptersEdit.shouldUpdateAPI() !== true) return of(true)
+
+    const body = { chapters: chaptersEdit.getChaptersForUpdate() } as VideoChapterUpdate
+
+    return this.authHttp.put(`${VideoService.BASE_VIDEO_URL}/${videoId}/chapters`, body)
+      .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+}
diff --git a/client/src/app/shared/shared-main/video/video-chapters-edit.model.ts b/client/src/app/shared/shared-main/video/video-chapters-edit.model.ts
new file mode 100644
index 000000000..6d7496ed6
--- /dev/null
+++ b/client/src/app/shared/shared-main/video/video-chapters-edit.model.ts
@@ -0,0 +1,43 @@
+import { simpleObjectsDeepEqual, sortBy } from '@peertube/peertube-core-utils'
+import { VideoChapter } from '@peertube/peertube-models'
+
+export class VideoChaptersEdit {
+  private chaptersFromAPI: VideoChapter[] = []
+
+  private chapters: VideoChapter[]
+
+  loadFromAPI (chapters: VideoChapter[]) {
+    this.chapters = chapters || []
+
+    this.chaptersFromAPI = chapters
+  }
+
+  patch (values: { [ id: string ]: any }) {
+    const chapters = values.chapters || []
+
+    this.chapters = chapters.map((c: any) => {
+      return {
+        timecode: c.timecode || 0,
+        title: c.title
+      }
+    })
+  }
+
+  toFormPatch () {
+    return { chapters: this.chapters }
+  }
+
+  getChaptersForUpdate (): VideoChapter[] {
+    return this.chapters.filter(c => !!c.title)
+  }
+
+  hasDuplicateValues () {
+    const timecodes = this.chapters.map(c => c.timecode)
+
+    return new Set(timecodes).size !== this.chapters.length
+  }
+
+  shouldUpdateAPI () {
+    return simpleObjectsDeepEqual(sortBy(this.getChaptersForUpdate(), 'timecode'), this.chaptersFromAPI) !== true
+  }
+}
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts
index 111b4645b..192b2e124 100644
--- a/client/src/assets/player/peertube-player.ts
+++ b/client/src/assets/player/peertube-player.ts
@@ -7,6 +7,8 @@ import './shared/bezels/bezels-plugin'
 import './shared/peertube/peertube-plugin'
 import './shared/resolutions/peertube-resolutions-plugin'
 import './shared/control-bar/storyboard-plugin'
+import './shared/control-bar/chapters-plugin'
+import './shared/control-bar/time-tooltip'
 import './shared/control-bar/next-previous-video-button'
 import './shared/control-bar/p2p-info-button'
 import './shared/control-bar/peertube-link-button'
@@ -227,6 +229,7 @@ export class PeerTubePlayer {
     if (this.player.usingPlugin('upnext')) this.player.upnext().dispose()
     if (this.player.usingPlugin('stats')) this.player.stats().dispose()
     if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose()
+    if (this.player.usingPlugin('chapters')) this.player.chapters().dispose()
 
     if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose()
 
@@ -273,6 +276,10 @@ export class PeerTubePlayer {
       this.player.storyboard(this.currentLoadOptions.storyboard)
     }
 
+    if (this.currentLoadOptions.videoChapters) {
+      this.player.chapters({ chapters: this.currentLoadOptions.videoChapters })
+    }
+
     if (this.currentLoadOptions.dock) {
       this.player.peertubeDock(this.currentLoadOptions.dock)
     }
diff --git a/client/src/assets/player/shared/control-bar/chapters-plugin.ts b/client/src/assets/player/shared/control-bar/chapters-plugin.ts
new file mode 100644
index 000000000..5be081694
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/chapters-plugin.ts
@@ -0,0 +1,64 @@
+import videojs from 'video.js'
+import { ChaptersOptions } from '../../types'
+import { VideoChapter } from '@peertube/peertube-models'
+import { ProgressBarMarkerComponent } from './progress-bar-marker-component'
+
+const Plugin = videojs.getPlugin('plugin')
+
+class ChaptersPlugin extends Plugin {
+  private chapters: VideoChapter[] = []
+  private markers: ProgressBarMarkerComponent[] = []
+
+  constructor (player: videojs.Player, options: videojs.ComponentOptions & ChaptersOptions) {
+    super(player, options)
+
+    this.chapters = options.chapters
+
+    this.player.ready(() => {
+      player.addClass('vjs-chapters')
+
+      this.player.one('durationchange', () => {
+        for (const chapter of this.chapters) {
+          if (chapter.timecode === 0) continue
+
+          const marker = new ProgressBarMarkerComponent(player, { timecode: chapter.timecode })
+
+          this.markers.push(marker)
+          this.getSeekBar().addChild(marker)
+        }
+      })
+    })
+  }
+
+  dispose () {
+    for (const marker of this.markers) {
+      this.getSeekBar().removeChild(marker)
+    }
+  }
+
+  getChapter (timecode: number) {
+    if (this.chapters.length !== 0) {
+      for (let i = this.chapters.length - 1; i >= 0; i--) {
+        const chapter = this.chapters[i]
+
+        if (chapter.timecode <= timecode) {
+          this.player.addClass('has-chapter')
+
+          return chapter.title
+        }
+      }
+    }
+
+    this.player.removeClass('has-chapter')
+
+    return ''
+  }
+
+  private getSeekBar () {
+    return this.player.getDescendant('ControlBar', 'ProgressControl', 'SeekBar')
+  }
+}
+
+videojs.registerPlugin('chapters', ChaptersPlugin)
+
+export { ChaptersPlugin }
diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts
index 9307027f6..091e876e2 100644
--- a/client/src/assets/player/shared/control-bar/index.ts
+++ b/client/src/assets/player/shared/control-bar/index.ts
@@ -1,6 +1,8 @@
+export * from './chapters-plugin'
 export * from './next-previous-video-button'
 export * from './p2p-info-button'
 export * from './peertube-link-button'
 export * from './peertube-live-display'
 export * from './storyboard-plugin'
 export * from './theater-button'
+export * from './time-tooltip'
diff --git a/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts b/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts
new file mode 100644
index 000000000..50965ec71
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts
@@ -0,0 +1,24 @@
+import videojs from 'video.js'
+import { ProgressBarMarkerComponentOptions } from '../../types'
+
+const Component = videojs.getComponent('Component')
+
+export class ProgressBarMarkerComponent extends Component {
+  options_: ProgressBarMarkerComponentOptions & videojs.ComponentOptions
+
+  // eslint-disable-next-line @typescript-eslint/no-useless-constructor
+  constructor (player: videojs.Player, options?: ProgressBarMarkerComponentOptions & videojs.ComponentOptions) {
+    super(player, options)
+  }
+
+  createEl () {
+    const left = (this.options_.timecode / this.player().duration()) * 100
+
+    return videojs.dom.createEl('span', {
+      className: 'vjs-marker',
+      style: `left: ${left}%`
+    }) as HTMLButtonElement
+  }
+}
+
+videojs.registerComponent('ProgressBarMarkerComponent', ProgressBarMarkerComponent)
diff --git a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts
index 80c69b5f2..91d7f451e 100644
--- a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts
+++ b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts
@@ -141,7 +141,9 @@ class StoryboardPlugin extends Plugin {
       const ctop = Math.floor(position / columns) * -scaledHeight
 
       const bgSize = `${imgWidth * scaleFactor}px ${imgHeight * scaleFactor}px`
-      const topOffset = -scaledHeight - 60
+
+      const timeTooltip = this.player.el().querySelector('.vjs-time-tooltip')
+      const topOffset = -scaledHeight + parseInt(getComputedStyle(timeTooltip).top.replace('px', '')) - 20
 
       const previewHalfSize = Math.round(scaledWidth / 2)
       let left = seekBarRect.width * seekBarX - previewHalfSize
diff --git a/client/src/assets/player/shared/control-bar/time-tooltip.ts b/client/src/assets/player/shared/control-bar/time-tooltip.ts
new file mode 100644
index 000000000..2ed4f9acd
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/time-tooltip.ts
@@ -0,0 +1,20 @@
+import { timeToInt } from '@peertube/peertube-core-utils'
+import videojs, { VideoJsPlayer } from 'video.js'
+
+const TimeToolTip = videojs.getComponent('TimeTooltip') as any // FIXME: typings don't have write method
+
+class TimeTooltip extends TimeToolTip {
+
+  write (timecode: string) {
+    const player: VideoJsPlayer = this.player()
+
+    if (player.usingPlugin('chapters')) {
+      const chapterTitle = player.chapters().getChapter(timeToInt(timecode))
+      if (chapterTitle) return super.write(chapterTitle + '\r\n' + timecode)
+    }
+
+    return super.write(timecode)
+  }
+}
+
+videojs.registerComponent('TimeTooltip', TimeTooltip)
diff --git a/client/src/assets/player/types/peertube-player-options.ts b/client/src/assets/player/types/peertube-player-options.ts
index 6fb2f7913..32f26fa9e 100644
--- a/client/src/assets/player/types/peertube-player-options.ts
+++ b/client/src/assets/player/types/peertube-player-options.ts
@@ -1,4 +1,4 @@
-import { LiveVideoLatencyModeType, VideoFile } from '@peertube/peertube-models'
+import { LiveVideoLatencyModeType, VideoChapter, VideoFile } from '@peertube/peertube-models'
 import { PluginsManager } from '@root-helpers/plugins-manager'
 import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
 import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings'
@@ -68,6 +68,7 @@ export type PeerTubePlayerLoadOptions = {
   }
 
   videoCaptions: VideoJSCaption[]
+  videoChapters: VideoChapter[]
   storyboard: VideoJSStoryboard
 
   videoUUID: string
diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts
index 27fbda31d..6293404ab 100644
--- a/client/src/assets/player/types/peertube-videojs-typings.ts
+++ b/client/src/assets/player/types/peertube-videojs-typings.ts
@@ -1,7 +1,7 @@
 import { HlsConfig, Level } from 'hls.js'
 import videojs from 'video.js'
 import { Engine } from '@peertube/p2p-media-loader-hlsjs'
-import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models'
+import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models'
 import { BezelsPlugin } from '../shared/bezels/bezels-plugin'
 import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin'
 import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
@@ -19,6 +19,7 @@ import { UpNextPlugin } from '../shared/upnext/upnext-plugin'
 import { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
 import { PlayerMode } from './peertube-player-options'
 import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
+import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin'
 
 declare module 'video.js' {
 
@@ -62,6 +63,8 @@ declare module 'video.js' {
 
     peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin
 
+    chapters (options?: ChaptersOptions): ChaptersPlugin
+
     upnext (options?: UpNextPluginOptions): UpNextPlugin
 
     playlist (options?: PlaylistPluginOptions): PlaylistPlugin
@@ -142,6 +145,10 @@ type StoryboardOptions = {
   interval: number
 }
 
+type ChaptersOptions = {
+  chapters: VideoChapter[]
+}
+
 type PlaylistPluginOptions = {
   elements: VideoPlaylistElement[]
 
@@ -161,6 +168,10 @@ type UpNextPluginOptions = {
   isSuspended: () => boolean
 }
 
+type ProgressBarMarkerComponentOptions = {
+  timecode: number
+}
+
 type NextPreviousVideoButtonOptions = {
   type: 'next' | 'previous'
   handler?: () => void
@@ -273,6 +284,7 @@ export {
   NextPreviousVideoButtonOptions,
   ResolutionUpdateData,
   AutoResolutionUpdateData,
+  ProgressBarMarkerComponentOptions,
   PlaylistPluginOptions,
   MetricsPluginOptions,
   VideoJSCaption,
@@ -284,5 +296,6 @@ export {
   UpNextPluginOptions,
   LoadedQualityData,
   StoryboardOptions,
+  ChaptersOptions,
   PeerTubeLinkButtonOptions
 }
diff --git a/client/src/sass/player/control-bar.scss b/client/src/sass/player/control-bar.scss
index 09a75e2fd..f272f3848 100644
--- a/client/src/sass/player/control-bar.scss
+++ b/client/src/sass/player/control-bar.scss
@@ -3,6 +3,16 @@
 @use '_mixins' as *;
 @use './_player-variables' as *;
 
+.vjs-peertube-skin.has-chapter {
+  .vjs-time-tooltip {
+    white-space: pre;
+    line-height: 1.5;
+    padding-top: 4px;
+    padding-bottom: 4px;
+    top: -4.9em;
+  }
+}
+
 .video-js.vjs-peertube-skin .vjs-control-bar {
   z-index: 100;
 
@@ -495,3 +505,12 @@
     }
   }
 }
+
+.vjs-marker {
+  position: absolute;
+  width: 3px;
+  opacity: .5;
+  background-color: #000;
+  height: 100%;
+  top: 0;
+}
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index e4f723079..78c5e5592 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -195,10 +195,11 @@ export class PeerTubeEmbed {
       const {
         videoResponse,
         captionsPromise,
+        chaptersPromise,
         storyboardsPromise
       } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword })
 
-      return this.buildVideoPlayer({ videoResponse, captionsPromise, storyboardsPromise, forceAutoplay })
+      return this.buildVideoPlayer({ videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay })
     } catch (err) {
 
       if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
@@ -210,9 +211,10 @@ export class PeerTubeEmbed {
     videoResponse: Response
     storyboardsPromise: Promise<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,
diff --git a/client/src/standalone/videos/shared/player-options-builder.ts b/client/src/standalone/videos/shared/player-options-builder.ts
index 3437ef421..dec859409 100644
--- a/client/src/standalone/videos/shared/player-options-builder.ts
+++ b/client/src/standalone/videos/shared/player-options-builder.ts
@@ -5,6 +5,7 @@ import {
   Storyboard,
   Video,
   VideoCaption,
+  VideoChapter,
   VideoDetails,
   VideoPlaylistElement,
   VideoState,
@@ -199,6 +200,8 @@ export class PlayerOptionsBuilder {
 
     storyboardsResponse: Response
 
+    chaptersResponse: Response
+
     live?: LiveVideo
 
     alreadyPlayed: boolean
@@ -229,12 +232,14 @@ export class PlayerOptionsBuilder {
       forceAutoplay,
       playlist,
       live,
-      storyboardsResponse
+      storyboardsResponse,
+      chaptersResponse
     } = options
 
-    const [ videoCaptions, storyboard ] = await Promise.all([
+    const [ videoCaptions, storyboard, chapters ] = await Promise.all([
       this.buildCaptions(captionsResponse, translations),
-      this.buildStoryboard(storyboardsResponse)
+      this.buildStoryboard(storyboardsResponse),
+      this.buildChapters(chaptersResponse)
     ])
 
     return {
@@ -248,6 +253,7 @@ export class PlayerOptionsBuilder {
       subtitle: this.subtitle,
 
       storyboard,
+      videoChapters: chapters,
 
       startTime: playlist
         ? playlist.playlistTracker.getCurrentElement().startTimestamp
@@ -312,6 +318,12 @@ export class PlayerOptionsBuilder {
     }
   }
 
+  private async buildChapters (chaptersResponse: Response) {
+    const { chapters } = await chaptersResponse.json() as { chapters: VideoChapter[] }
+
+    return chapters
+  }
+
   private buildPlaylistOptions (options?: {
     playlistTracker: PlaylistTracker
     playNext: () => any
diff --git a/client/src/standalone/videos/shared/video-fetcher.ts b/client/src/standalone/videos/shared/video-fetcher.ts
index 9149d946e..c52861189 100644
--- a/client/src/standalone/videos/shared/video-fetcher.ts
+++ b/client/src/standalone/videos/shared/video-fetcher.ts
@@ -36,9 +36,10 @@ export class VideoFetcher {
     }
 
     const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword })
+    const chaptersPromise = this.loadVideoChapters({ videoId, videoPassword })
     const storyboardsPromise = this.loadStoryboards(videoId)
 
-    return { captionsPromise, storyboardsPromise, videoResponse }
+    return { captionsPromise, chaptersPromise, storyboardsPromise, videoResponse }
   }
 
   loadLive (video: VideoDetails) {
@@ -64,6 +65,10 @@ export class VideoFetcher {
     return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword)
   }
 
+  private loadVideoChapters ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
+    return this.http.fetch(this.getVideoUrl(videoId) + '/chapters', { optionalAuth: true }, videoPassword)
+  }
+
   private getVideoUrl (id: string) {
     return window.location.origin + '/api/v1/videos/' + id
   }
diff --git a/packages/core-utils/src/common/array.ts b/packages/core-utils/src/common/array.ts
index 878ed1ffe..3978ddd16 100644
--- a/packages/core-utils/src/common/array.ts
+++ b/packages/core-utils/src/common/array.ts
@@ -1,4 +1,4 @@
-function findCommonElement <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
+  })
 }
diff --git a/packages/core-utils/src/common/date.ts b/packages/core-utils/src/common/date.ts
index f0684ff86..66899de80 100644
--- a/packages/core-utils/src/common/date.ts
+++ b/packages/core-utils/src/common/date.ts
@@ -45,11 +45,13 @@ function isLastWeek (d: Date) {
 
 // ---------------------------------------------------------------------------
 
+export const timecodeRegexString = `((\\d+)[h:])?((\\d+)[m:])?((\\d+)s?)?`
+
 function timeToInt (time: number | string) {
   if (!time) return 0
   if (typeof time === 'number') return time
 
-  const reg = /^((\d+)[h:])?((\d+)[m:])?((\d+)s?)?$/
+  const reg = new RegExp(`^${timecodeRegexString}$`)
   const matches = time.match(reg)
 
   if (!matches) return 0
diff --git a/packages/core-utils/src/index.ts b/packages/core-utils/src/index.ts
index 3ca5d9d47..69fa2c046 100644
--- a/packages/core-utils/src/index.ts
+++ b/packages/core-utils/src/index.ts
@@ -5,3 +5,4 @@ export * from './plugins/index.js'
 export * from './renderer/index.js'
 export * from './users/index.js'
 export * from './videos/index.js'
+export * from './string/index.js'
diff --git a/packages/core-utils/src/string/chapters.ts b/packages/core-utils/src/string/chapters.ts
new file mode 100644
index 000000000..d7643665c
--- /dev/null
+++ b/packages/core-utils/src/string/chapters.ts
@@ -0,0 +1,32 @@
+import { timeToInt, timecodeRegexString } from '../common/date.js'
+
+const timecodeRegex = new RegExp(`^(${timecodeRegexString})\\s`)
+
+export function parseChapters (text: string) {
+  if (!text) return []
+
+  const lines = text.split(/\r?\n|\r|\n/g)
+  let foundChapters = false
+
+  const chapters: { timecode: number, title: string }[] = []
+
+  for (const line of lines) {
+    const matched = line.match(timecodeRegex)
+    if (!matched) {
+      // Stop chapters parsing
+      if (foundChapters) break
+
+      continue
+    }
+
+    foundChapters = true
+
+    const timecodeText = matched[1]
+    const timecode = timeToInt(timecodeText)
+    const title = line.replace(matched[0], '')
+
+    chapters.push({ timecode, title })
+  }
+
+  return chapters
+}
diff --git a/packages/core-utils/src/string/index.ts b/packages/core-utils/src/string/index.ts
new file mode 100644
index 000000000..42680ab16
--- /dev/null
+++ b/packages/core-utils/src/string/index.ts
@@ -0,0 +1 @@
+export * from './chapters.js'
diff --git a/packages/ffmpeg/src/ffprobe.ts b/packages/ffmpeg/src/ffprobe.ts
index ed1742ab1..f995e7925 100644
--- a/packages/ffmpeg/src/ffprobe.ts
+++ b/packages/ffmpeg/src/ffprobe.ts
@@ -10,7 +10,7 @@ import { VideoResolution } from '@peertube/peertube-models'
 
 function ffprobePromise (path: string) {
   return new Promise<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,
diff --git a/packages/models/src/activitypub/context.ts b/packages/models/src/activitypub/context.ts
index e9df38207..e52463c6c 100644
--- a/packages/models/src/activitypub/context.ts
+++ b/packages/models/src/activitypub/context.ts
@@ -13,4 +13,5 @@ export type ContextType =
   'Flag' |
   'Actor' |
   'Collection' |
-  'WatchAction'
+  'WatchAction' |
+  'Chapters'
diff --git a/packages/models/src/activitypub/objects/index.ts b/packages/models/src/activitypub/objects/index.ts
index 510f621ea..8e21f584f 100644
--- a/packages/models/src/activitypub/objects/index.ts
+++ b/packages/models/src/activitypub/objects/index.ts
@@ -4,6 +4,7 @@ export * from './cache-file-object.js'
 export * from './common-objects.js'
 export * from './playlist-element-object.js'
 export * from './playlist-object.js'
+export * from './video-chapters-object.js'
 export * from './video-comment-object.js'
 export * from './video-object.js'
 export * from './watch-action-object.js'
diff --git a/packages/models/src/activitypub/objects/video-chapters-object.ts b/packages/models/src/activitypub/objects/video-chapters-object.ts
new file mode 100644
index 000000000..0149c6e87
--- /dev/null
+++ b/packages/models/src/activitypub/objects/video-chapters-object.ts
@@ -0,0 +1,11 @@
+export interface VideoChaptersObject {
+  id: string
+  hasPart: VideoChapterObject[]
+}
+
+// Same as https://schema.org/hasPart
+export interface VideoChapterObject {
+  name: string
+  startOffset: number
+  endOffset: number
+}
diff --git a/packages/models/src/activitypub/objects/video-object.ts b/packages/models/src/activitypub/objects/video-object.ts
index 14afd85a2..9abae6a39 100644
--- a/packages/models/src/activitypub/objects/video-object.ts
+++ b/packages/models/src/activitypub/objects/video-object.ts
@@ -50,6 +50,7 @@ export interface VideoObject {
   dislikes: string
   shares: string
   comments: string
+  hasParts: string
 
   attributedTo: ActivityPubAttributedTo[]
 
diff --git a/packages/models/src/videos/chapter/chapter-update.model.ts b/packages/models/src/videos/chapter/chapter-update.model.ts
new file mode 100644
index 000000000..82b2091af
--- /dev/null
+++ b/packages/models/src/videos/chapter/chapter-update.model.ts
@@ -0,0 +1,6 @@
+export interface VideoChapterUpdate {
+  chapters: {
+    timecode: number
+    title: string
+  }[]
+}
diff --git a/packages/models/src/videos/chapter/chapter.model.ts b/packages/models/src/videos/chapter/chapter.model.ts
new file mode 100644
index 000000000..7ecba61bc
--- /dev/null
+++ b/packages/models/src/videos/chapter/chapter.model.ts
@@ -0,0 +1,4 @@
+export interface VideoChapter {
+  timecode: number
+  title: string
+}
diff --git a/packages/models/src/videos/chapter/index.ts b/packages/models/src/videos/chapter/index.ts
new file mode 100644
index 000000000..15fca476f
--- /dev/null
+++ b/packages/models/src/videos/chapter/index.ts
@@ -0,0 +1,2 @@
+export * from './chapter-update.model.js'
+export * from './chapter.model.js'
diff --git a/packages/models/src/videos/index.ts b/packages/models/src/videos/index.ts
index d131212c9..7d96d31a6 100644
--- a/packages/models/src/videos/index.ts
+++ b/packages/models/src/videos/index.ts
@@ -12,6 +12,7 @@ export * from './rate/index.js'
 export * from './stats/index.js'
 export * from './transcoding/index.js'
 export * from './channel-sync/index.js'
+export * from './chapter/index.js'
 
 export * from './nsfw-policy.type.js'
 
diff --git a/packages/server-commands/src/server/server.ts b/packages/server-commands/src/server/server.ts
index 57a897c17..3911a6fad 100644
--- a/packages/server-commands/src/server/server.ts
+++ b/packages/server-commands/src/server/server.ts
@@ -30,6 +30,7 @@ import {
   ChangeOwnershipCommand,
   ChannelsCommand,
   ChannelSyncsCommand,
+  ChaptersCommand,
   CommentsCommand,
   HistoryCommand,
   ImportsCommand,
@@ -152,6 +153,7 @@ export class PeerTubeServer {
   videoPasswords?: VideoPasswordsCommand
 
   storyboard?: StoryboardCommand
+  chapters?: ChaptersCommand
 
   runners?: RunnersCommand
   runnerRegistrationTokens?: RunnerRegistrationTokensCommand
@@ -442,6 +444,7 @@ export class PeerTubeServer {
     this.registrations = new RegistrationsCommand(this)
 
     this.storyboard = new StoryboardCommand(this)
+    this.chapters = new ChaptersCommand(this)
 
     this.runners = new RunnersCommand(this)
     this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this)
diff --git a/packages/server-commands/src/videos/chapters-command.ts b/packages/server-commands/src/videos/chapters-command.ts
new file mode 100644
index 000000000..8a75c7fae
--- /dev/null
+++ b/packages/server-commands/src/videos/chapters-command.ts
@@ -0,0 +1,38 @@
+import {
+  HttpStatusCode, VideoChapterUpdate, VideoChapters
+} from '@peertube/peertube-models'
+import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
+
+export class ChaptersCommand extends AbstractCommand {
+
+  list (options: OverrideCommandOptions & {
+    videoId: string | number
+  }) {
+    const path = '/api/v1/videos/' + options.videoId + '/chapters'
+
+    return this.getRequestBody<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
+    })
+  }
+}
diff --git a/packages/server-commands/src/videos/index.ts b/packages/server-commands/src/videos/index.ts
index 970026d51..8d193e24c 100644
--- a/packages/server-commands/src/videos/index.ts
+++ b/packages/server-commands/src/videos/index.ts
@@ -3,6 +3,7 @@ export * from './captions-command.js'
 export * from './change-ownership-command.js'
 export * from './channels.js'
 export * from './channels-command.js'
+export * from './chapters-command.js'
 export * from './channel-syncs-command.js'
 export * from './comments-command.js'
 export * from './history-command.js'
diff --git a/packages/tests/fixtures/video_chapters.mp4 b/packages/tests/fixtures/video_chapters.mp4
new file mode 100644
index 0000000000000000000000000000000000000000..46cbaf6245bc7798aee40c9db95d9bc91e46677f
GIT binary patch
literal 39611
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
a-nL#$U_t@Hg1o;;z;pnrS{eT)5&l1!YCnho

literal 0
HcmV?d00001

diff --git a/packages/tests/src/api/check-params/index.ts b/packages/tests/src/api/check-params/index.ts
index ed5fe6b06..d7867e8a5 100644
--- a/packages/tests/src/api/check-params/index.ts
+++ b/packages/tests/src/api/check-params/index.ts
@@ -30,6 +30,7 @@ import './video-blacklist.js'
 import './video-captions.js'
 import './video-channel-syncs.js'
 import './video-channels.js'
+import './video-chapters.js'
 import './video-comments.js'
 import './video-files.js'
 import './video-imports.js'
diff --git a/packages/tests/src/api/check-params/video-captions.ts b/packages/tests/src/api/check-params/video-captions.ts
index 4150b095f..ac4e85068 100644
--- a/packages/tests/src/api/check-params/video-captions.ts
+++ b/packages/tests/src/api/check-params/video-captions.ts
@@ -31,15 +31,7 @@ describe('Test video captions API validator', function () {
 
     video = await server.videos.upload()
     privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } })
-
-    {
-      const user = {
-        username: 'user1',
-        password: 'my super password'
-      }
-      await server.users.create({ username: user.username, password: user.password })
-      userAccessToken = await server.login.getAccessToken(user)
-    }
+    userAccessToken = await server.users.generateUserAndToken('user1')
   })
 
   describe('When adding video caption', function () {
@@ -120,6 +112,19 @@ describe('Test video captions API validator', function () {
       })
     })
 
+    it('Should fail with another user token', async function () {
+      const captionPath = path + video.uuid + '/captions/fr'
+      await makeUploadRequest({
+        method: 'PUT',
+        url: server.url,
+        path: captionPath,
+        token: userAccessToken,
+        fields,
+        attaches,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
     // We accept any file now
     // it('Should fail with an invalid captionfile extension', async function () {
     //   const attaches = {
diff --git a/packages/tests/src/api/check-params/video-chapters.ts b/packages/tests/src/api/check-params/video-chapters.ts
new file mode 100644
index 000000000..c59f88e79
--- /dev/null
+++ b/packages/tests/src/api/check-params/video-chapters.ts
@@ -0,0 +1,172 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { HttpStatusCode, Video, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
+import {
+  PeerTubeServer,
+  cleanupTests,
+  createSingleServer,
+  setAccessTokensToServers,
+  setDefaultVideoChannel
+} from '@peertube/peertube-server-commands'
+
+describe('Test videos chapters API validator', function () {
+  let server: PeerTubeServer
+  let video: VideoCreateResult
+  let live: Video
+  let privateVideo: VideoCreateResult
+  let userAccessToken: string
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(30000)
+
+    server = await createSingleServer(1)
+
+    await setAccessTokensToServers([ server ])
+    await setDefaultVideoChannel([ server ])
+
+    video = await server.videos.upload()
+    privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } })
+    userAccessToken = await server.users.generateUserAndToken('user1')
+
+    await server.config.enableLive({ allowReplay: false })
+
+    const res = await server.live.quickCreate({ saveReplay: false, permanentLive: false })
+    live = res.video
+  })
+
+  describe('When updating chapters', function () {
+
+    it('Should fail without a valid uuid', async function () {
+      await server.chapters.update({ videoId: '4da6fd', chapters: [], expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+    })
+
+    it('Should fail with an unknown id', async function () {
+      await server.chapters.update({
+        videoId: 'ce0801ef-7124-48df-9b22-b473ace78797',
+        chapters: [],
+        expectedStatus: HttpStatusCode.NOT_FOUND_404
+      })
+    })
+
+    it('Should fail without access token', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [],
+        token: null,
+        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+      })
+    })
+
+    it('Should fail with a bad access token', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [],
+        token: 'toto',
+        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+      })
+    })
+
+    it('Should fail with a another user access token', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [],
+        token: userAccessToken,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should fail with a wrong chapters param', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: 'hello' as any,
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail with a bad chapter title', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [ { title: 'hello', timecode: 21 }, { title: '', timecode: 21 } ],
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [ { title: 'hello', timecode: 21 }, { title: 'a'.repeat(150), timecode: 21 } ],
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail with a bad timecode', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [ { title: 'hello', timecode: 21 }, { title: 'title', timecode: -5 } ],
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [ { title: 'hello', timecode: 21 }, { title: 'title', timecode: 'hi' as any } ],
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail with non unique timecodes', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [ { title: 'hello', timecode: 21 }, { title: 'title', timecode: 22 }, { title: 'hello', timecode: 21 } ],
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail to create chapters on a live', async function () {
+      await server.chapters.update({
+        videoId: live.id,
+        chapters: [],
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: []
+      })
+
+      await server.chapters.update({
+        videoId: video.id,
+        chapters: [ { title: 'hello', timecode: 21 }, { title: 'hello 2', timecode: 35 } ]
+      })
+    })
+  })
+
+  describe('When listing chapters', function () {
+
+    it('Should fail without a valid uuid', async function () {
+      await server.chapters.list({ videoId: '4da6fd', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+    })
+
+    it('Should fail with an unknown id', async function () {
+      await server.chapters.list({ videoId: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+    })
+
+    it('Should not list private chapters to anyone', async function () {
+      await server.chapters.list({ videoId: privateVideo.uuid, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should not list private chapters to another user', async function () {
+      await server.chapters.list({ videoId: privateVideo.uuid, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+    })
+
+    it('Should list chapters', async function () {
+      await server.chapters.list({ videoId: privateVideo.uuid })
+      await server.chapters.list({ videoId: video.uuid })
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
diff --git a/packages/tests/src/api/videos/index.ts b/packages/tests/src/api/videos/index.ts
index fcb1d5a81..a4bcd9741 100644
--- a/packages/tests/src/api/videos/index.ts
+++ b/packages/tests/src/api/videos/index.ts
@@ -4,6 +4,7 @@ import './single-server.js'
 import './video-captions.js'
 import './video-change-ownership.js'
 import './video-channels.js'
+import './video-chapters.js'
 import './channel-import-videos.js'
 import './video-channel-syncs.js'
 import './video-comments.js'
diff --git a/packages/tests/src/api/videos/video-chapters.ts b/packages/tests/src/api/videos/video-chapters.ts
new file mode 100644
index 000000000..2f3dbcd2e
--- /dev/null
+++ b/packages/tests/src/api/videos/video-chapters.ts
@@ -0,0 +1,342 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { VideoChapter, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
+import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils'
+import {
+  cleanupTests,
+  createMultipleServers,
+  doubleFollow, PeerTubeServer, setAccessTokensToServers,
+  setDefaultVideoChannel,
+  waitJobs
+} from '@peertube/peertube-server-commands'
+import { FIXTURE_URLS } from '@tests/shared/tests.js'
+import { expect } from 'chai'
+
+describe('Test video chapters', function () {
+  let servers: PeerTubeServer[]
+
+  before(async function () {
+    this.timeout(120000)
+
+    servers = await createMultipleServers(2)
+    await setAccessTokensToServers(servers)
+    await setDefaultVideoChannel(servers)
+
+    await doubleFollow(servers[0], servers[1])
+  })
+
+  describe('Common tests', function () {
+    let video: VideoCreateResult
+
+    before(async function () {
+      this.timeout(120000)
+
+      video = await servers[0].videos.quickUpload({ name: 'video' })
+      await waitJobs(servers)
+    })
+
+    it('Should not have chapters', async function () {
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([])
+      }
+    })
+
+    it('Should set chaptets', async function () {
+      await servers[0].chapters.update({
+        videoId: video.uuid,
+        chapters: [
+          { title: 'chapter 1', timecode: 45 },
+          { title: 'chapter 2', timecode: 58 }
+        ]
+      })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([
+          { title: 'chapter 1', timecode: 45 },
+          { title: 'chapter 2', timecode: 58 }
+        ])
+      }
+    })
+
+    it('Should add new chapters', async function () {
+      await servers[0].chapters.update({
+        videoId: video.uuid,
+        chapters: [
+          { title: 'chapter 1', timecode: 45 },
+          { title: 'chapter 2', timecode: 46 },
+          { title: 'chapter 3', timecode: 58 }
+        ]
+      })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([
+          { title: 'chapter 1', timecode: 45 },
+          { title: 'chapter 2', timecode: 46 },
+          { title: 'chapter 3', timecode: 58 }
+        ])
+      }
+    })
+
+    it('Should delete all chapters', async function () {
+      await servers[0].chapters.update({ videoId: video.uuid, chapters: [] })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([])
+      }
+    })
+  })
+
+  describe('With chapters in description', function () {
+    const description = 'this is a super description\n' +
+      '00:00 chapter 1\n' +
+      '00:03 chapter 2\n' +
+      '00:04 chapter 3\n'
+
+    function checkChapters (chapters: VideoChapter[]) {
+      expect(chapters).to.deep.equal([
+        {
+          timecode: 0,
+          title: 'chapter 1'
+        },
+        {
+          timecode: 3,
+          title: 'chapter 2'
+        },
+        {
+          timecode: 4,
+          title: 'chapter 3'
+        }
+      ])
+    }
+
+    it('Should upload a video with chapters in description', async function () {
+      const video = await servers[0].videos.upload({ attributes: { name: 'description', description } })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        checkChapters(chapters)
+      }
+    })
+
+    it('Should update a video description and automatically add chapters', async function () {
+      const video = await servers[0].videos.quickUpload({ name: 'update description' })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([])
+      }
+
+      await servers[0].videos.update({ id: video.uuid, attributes: { description } })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        checkChapters(chapters)
+      }
+    })
+
+    it('Should update a video description but not automatically add chapters since the video already has chapters', async function () {
+      const video = await servers[0].videos.quickUpload({ name: 'update description' })
+
+      await servers[0].chapters.update({ videoId: video.uuid, chapters: [ { timecode: 5, title: 'chapter 1' } ] })
+      await servers[0].videos.update({ id: video.uuid, attributes: { description } })
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([ { timecode: 5, title: 'chapter 1' } ])
+      }
+    })
+
+    it('Should update multiple times chapters from description', async function () {
+      const video = await servers[0].videos.quickUpload({ name: 'update description' })
+
+      await servers[0].videos.update({ id: video.uuid, attributes: { description } })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        checkChapters(chapters)
+      }
+
+      await servers[0].videos.update({ id: video.uuid, attributes: { description: '00:01 chapter 1' } })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([ { timecode: 1, title: 'chapter 1' } ])
+      }
+
+      await servers[0].videos.update({ id: video.uuid, attributes: { description: 'null description' } })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([])
+      }
+    })
+  })
+
+  describe('With upload', function () {
+
+    it('Should upload a mp4 containing chapters and automatically add them', async function () {
+      const video = await servers[0].videos.quickUpload({ fixture: 'video_chapters.mp4', name: 'chapters' })
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([
+          {
+            timecode: 0,
+            title: 'Chapter 1'
+          },
+          {
+            timecode: 2,
+            title: 'Chapter 2'
+          },
+          {
+            timecode: 4,
+            title: 'Chapter 3'
+          }
+        ])
+      }
+    })
+  })
+
+  describe('With URL import', function () {
+    if (areHttpImportTestsDisabled()) return
+
+    it('Should detect chapters from youtube URL import', async function () {
+      this.timeout(120000)
+
+      const attributes = {
+        channelId: servers[0].store.channel.id,
+        privacy: VideoPrivacy.PUBLIC,
+        targetUrl: FIXTURE_URLS.youtubeChapters,
+        description: 'this is a super description\n'
+      }
+      const { video } = await servers[0].imports.importVideo({ attributes })
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([
+          {
+            timecode: 0,
+            title: 'chapter 1'
+          },
+          {
+            timecode: 15,
+            title: 'chapter 2'
+          },
+          {
+            timecode: 35,
+            title: 'chapter 3'
+          },
+          {
+            timecode: 40,
+            title: 'chapter 4'
+          }
+        ])
+      }
+    })
+
+    it('Should have overriden description priority from youtube URL import', async function () {
+      this.timeout(120000)
+
+      const attributes = {
+        channelId: servers[0].store.channel.id,
+        privacy: VideoPrivacy.PUBLIC,
+        targetUrl: FIXTURE_URLS.youtubeChapters,
+        description: 'this is a super description\n' +
+          '00:00 chapter 1\n' +
+          '00:03 chapter 2\n' +
+          '00:04 chapter 3\n'
+      }
+      const { video } = await servers[0].imports.importVideo({ attributes })
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([
+          {
+            timecode: 0,
+            title: 'chapter 1'
+          },
+          {
+            timecode: 3,
+            title: 'chapter 2'
+          },
+          {
+            timecode: 4,
+            title: 'chapter 3'
+          }
+        ])
+      }
+    })
+
+    it('Should detect chapters from raw URL import', async function () {
+      this.timeout(120000)
+
+      const attributes = {
+        channelId: servers[0].store.channel.id,
+        privacy: VideoPrivacy.PUBLIC,
+        targetUrl: FIXTURE_URLS.chatersVideo
+      }
+      const { video } = await servers[0].imports.importVideo({ attributes })
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { chapters } = await server.chapters.list({ videoId: video.uuid })
+
+        expect(chapters).to.deep.equal([
+          {
+            timecode: 0,
+            title: 'Chapter 1'
+          },
+          {
+            timecode: 2,
+            title: 'Chapter 2'
+          },
+          {
+            timecode: 4,
+            title: 'Chapter 3'
+          }
+        ])
+      }
+    })
+  })
+
+  // TODO: test torrent import too
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})
diff --git a/packages/tests/src/server-helpers/core-utils.ts b/packages/tests/src/server-helpers/core-utils.ts
index d61cae855..0df238e88 100644
--- a/packages/tests/src/server-helpers/core-utils.ts
+++ b/packages/tests/src/server-helpers/core-utils.ts
@@ -3,7 +3,7 @@
 import { expect } from 'chai'
 import snakeCase from 'lodash-es/snakeCase.js'
 import validator from 'validator'
-import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate } from '@peertube/peertube-core-utils'
+import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, parseChapters } from '@peertube/peertube-core-utils'
 import { VideoResolution } from '@peertube/peertube-models'
 import { objectConverter, parseBytes, parseDurationToMs, parseSemVersion } from '@peertube/peertube-server/server/helpers/core-utils.js'
 
@@ -199,3 +199,28 @@ describe('Parse semantic version string', function () {
     expect(actual.patch).to.equal(0)
   })
 })
+
+describe('Extract chapters', function () {
+
+  it('Should not extract chapters', function () {
+    expect(parseChapters('my super description\nno?')).to.deep.equal([])
+    expect(parseChapters('m00:00 super description\nno?')).to.deep.equal([])
+    expect(parseChapters('00:00super description\nno?')).to.deep.equal([])
+  })
+
+  it('Should extract chapters', function () {
+    expect(parseChapters('00:00 coucou')).to.deep.equal([ { timecode: 0, title: 'coucou' } ])
+    expect(parseChapters('my super description\n\n00:01:30 chapter 1\n00:01:35 chapter 2')).to.deep.equal([
+      { timecode: 90, title: 'chapter 1' },
+      { timecode: 95, title: 'chapter 2' }
+    ])
+    expect(parseChapters('hi\n\n00:01:30 chapter 1\n00:01:35 chapter 2\nhi')).to.deep.equal([
+      { timecode: 90, title: 'chapter 1' },
+      { timecode: 95, title: 'chapter 2' }
+    ])
+    expect(parseChapters('hi\n\n00:01:30 chapter 1\n00:01:35 chapter 2\nhi\n00:01:40 chapter 3')).to.deep.equal([
+      { timecode: 90, title: 'chapter 1' },
+      { timecode: 95, title: 'chapter 2' }
+    ])
+  })
+})
diff --git a/packages/tests/src/shared/tests.ts b/packages/tests/src/shared/tests.ts
index d2cb040fb..554ed0e1f 100644
--- a/packages/tests/src/shared/tests.ts
+++ b/packages/tests/src/shared/tests.ts
@@ -3,6 +3,7 @@ const FIXTURE_URLS = {
   peertube_short: 'https://peertube2.cpy.re/w/3fbif9S3WmtTP8gGsC5HBd',
 
   youtube: 'https://www.youtube.com/watch?v=msX3jv1XdvM',
+  youtubeChapters: 'https://www.youtube.com/watch?v=TL9P-Er7ils',
 
   /**
    * The video is used to check format-selection correctness wrt. HDR,
@@ -26,6 +27,8 @@ const FIXTURE_URLS = {
   goodVideo: 'https://download.cpy.re/peertube/good_video.mp4',
   goodVideo720: 'https://download.cpy.re/peertube/good_video_720.mp4',
 
+  chatersVideo: 'https://download.cpy.re/peertube/video_chapters.mp4',
+
   file4K: 'https://download.cpy.re/peertube/4k_file.txt'
 }
 
diff --git a/packages/typescript-utils/src/types.ts b/packages/typescript-utils/src/types.ts
index 57cc23f1f..cd998a467 100644
--- a/packages/typescript-utils/src/types.ts
+++ b/packages/typescript-utils/src/types.ts
@@ -43,3 +43,5 @@ export type DeepOmit<T, 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
diff --git a/server/server/controllers/activitypub/client.ts b/server/server/controllers/activitypub/client.ts
index 5d5e43bf5..1d5d269a9 100644
--- a/server/server/controllers/activitypub/client.ts
+++ b/server/server/controllers/activitypub/client.ts
@@ -1,6 +1,13 @@
 import cors from 'cors'
 import express from 'express'
-import { VideoCommentObject, VideoPlaylistPrivacy, VideoPrivacy, VideoRateType } from '@peertube/peertube-models'
+import {
+  VideoChapterObject,
+  VideoChaptersObject,
+  VideoCommentObject,
+  VideoPlaylistPrivacy,
+  VideoPrivacy,
+  VideoRateType
+} from '@peertube/peertube-models'
 import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js'
 import { getContextFilter } from '@server/lib/activitypub/context.js'
 import { getServerActor } from '@server/models/application/application.js'
@@ -12,12 +19,18 @@ import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/act
 import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js'
 import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js'
 import {
+  getLocalVideoChaptersActivityPubUrl,
   getLocalVideoCommentsActivityPubUrl,
   getLocalVideoDislikesActivityPubUrl,
   getLocalVideoLikesActivityPubUrl,
   getLocalVideoSharesActivityPubUrl
 } from '../../lib/activitypub/url.js'
-import { cacheRoute } from '../../middlewares/cache/cache.js'
+import {
+  apVideoChaptersSetCacheKey,
+  buildAPVideoChaptersGroupsCache,
+  cacheRoute,
+  cacheRouteFactory
+} from '../../middlewares/cache/cache.js'
 import {
   activityPubRateLimiter,
   asyncMiddleware,
@@ -42,6 +55,8 @@ import { VideoCommentModel } from '../../models/video/video-comment.js'
 import { VideoPlaylistModel } from '../../models/video/video-playlist.js'
 import { VideoShareModel } from '../../models/video/video-share.js'
 import { activityPubResponse } from './utils.js'
+import { VideoChapterModel } from '@server/models/video/video-chapter.js'
+import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
 
 const activityPubClientRouter = express.Router()
 activityPubClientRouter.use(cors())
@@ -145,6 +160,27 @@ activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity
   asyncMiddleware(videoCommentController)
 )
 
+// ---------------------------------------------------------------------------
+
+const { middleware: chaptersCacheRouteMiddleware, instance: chaptersApiCache } = cacheRouteFactory()
+
+InternalEventEmitter.Instance.on('chapters-updated', ({ video }) => {
+  if (video.remote) return
+
+  chaptersApiCache.clearGroupSafe(buildAPVideoChaptersGroupsCache({ videoId: video.uuid }))
+})
+
+activityPubClientRouter.get('/videos/watch/:id/chapters',
+  executeIfActivityPub,
+  activityPubRateLimiter,
+  apVideoChaptersSetCacheKey,
+  chaptersCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS),
+  asyncMiddleware(videosCustomGetValidator('only-video')),
+  asyncMiddleware(videoChaptersController)
+)
+
+// ---------------------------------------------------------------------------
+
 activityPubClientRouter.get(
   [ '/video-channels/:nameWithHost', '/video-channels/:nameWithHost/videos', '/c/:nameWithHost', '/c/:nameWithHost/videos' ],
   executeIfActivityPub,
@@ -390,6 +426,31 @@ async function videoCommentController (req: express.Request, res: express.Respon
   return activityPubResponse(activityPubContextify(videoCommentObject, 'Comment', getContextFilter()), res)
 }
 
+async function videoChaptersController (req: express.Request, res: express.Response) {
+  const video = res.locals.onlyVideo
+
+  if (redirectIfNotOwned(video.url, res)) return
+
+  const chapters = await VideoChapterModel.listChaptersOfVideo(video.id)
+
+  const hasPart: VideoChapterObject[] = []
+
+  if (chapters.length !== 0) {
+    for (let i = 0; i < chapters.length - 1; i++) {
+      hasPart.push(chapters[i].toActivityPubJSON({ video, nextChapter: chapters[i + 1] }))
+    }
+
+    hasPart.push(chapters[chapters.length - 1].toActivityPubJSON({ video: res.locals.onlyVideo, nextChapter: null }))
+  }
+
+  const chaptersObject: VideoChaptersObject = {
+    id: getLocalVideoChaptersActivityPubUrl(video),
+    hasPart
+  }
+
+  return activityPubResponse(activityPubContextify(chaptersObject, 'Chapters', getContextFilter()), res)
+}
+
 async function videoRedundancyController (req: express.Request, res: express.Response) {
   const videoRedundancy = res.locals.videoRedundancy
 
diff --git a/server/server/controllers/api/videos/chapters.ts b/server/server/controllers/api/videos/chapters.ts
new file mode 100644
index 000000000..f744a2b56
--- /dev/null
+++ b/server/server/controllers/api/videos/chapters.ts
@@ -0,0 +1,51 @@
+import express from 'express'
+import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares/index.js'
+import { updateVideoChaptersValidator, videosCustomGetValidator } from '../../../middlewares/validators/index.js'
+import { VideoChapterModel } from '@server/models/video/video-chapter.js'
+import { HttpStatusCode, VideoChapterUpdate } from '@peertube/peertube-models'
+import { sequelizeTypescript } from '@server/initializers/database.js'
+import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
+import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js'
+import { replaceChapters } from '@server/lib/video-chapters.js'
+
+const videoChaptersRouter = express.Router()
+
+videoChaptersRouter.get('/:id/chapters',
+  asyncMiddleware(videosCustomGetValidator('only-video')),
+  asyncMiddleware(listVideoChapters)
+)
+
+videoChaptersRouter.put('/:videoId/chapters',
+  authenticate,
+  asyncMiddleware(updateVideoChaptersValidator),
+  asyncRetryTransactionMiddleware(replaceVideoChapters)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  videoChaptersRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function listVideoChapters (req: express.Request, res: express.Response) {
+  const chapters = await VideoChapterModel.listChaptersOfVideo(res.locals.onlyVideo.id)
+
+  return res.json({ chapters: chapters.map(c => c.toFormattedJSON()) })
+}
+
+async function replaceVideoChapters (req: express.Request, res: express.Response) {
+  const body = req.body as VideoChapterUpdate
+  const video = res.locals.videoAll
+
+  await retryTransactionWrapper(() => {
+    return sequelizeTypescript.transaction(async t => {
+      await replaceChapters({ video, chapters: body.chapters, transaction: t })
+
+      await federateVideoIfNeeded(video, false, t)
+    })
+  })
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
diff --git a/server/server/controllers/api/videos/index.ts b/server/server/controllers/api/videos/index.ts
index f8e3d9cb5..508cbb7c5 100644
--- a/server/server/controllers/api/videos/index.ts
+++ b/server/server/controllers/api/videos/index.ts
@@ -49,6 +49,7 @@ import { transcodingRouter } from './transcoding.js'
 import { updateRouter } from './update.js'
 import { uploadRouter } from './upload.js'
 import { viewRouter } from './view.js'
+import { videoChaptersRouter } from './chapters.js'
 
 const auditLogger = auditLoggerFactory('videos')
 const videosRouter = express.Router()
@@ -73,6 +74,7 @@ videosRouter.use('/', tokenRouter)
 videosRouter.use('/', videoPasswordRouter)
 videosRouter.use('/', storyboardRouter)
 videosRouter.use('/', videoSourceRouter)
+videosRouter.use('/', videoChaptersRouter)
 
 videosRouter.get('/categories',
   openapiOperationDoc({ operationId: 'getCategories' }),
diff --git a/server/server/controllers/api/videos/update.ts b/server/server/controllers/api/videos/update.ts
index 491175d74..5adc5e8e5 100644
--- a/server/server/controllers/api/videos/update.ts
+++ b/server/server/controllers/api/videos/update.ts
@@ -22,6 +22,7 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist.js'
 import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js'
 import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
 import { VideoModel } from '../../../models/video/video.js'
+import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
 
 const lTags = loggerTagsFactory('api', 'video')
 const auditLogger = auditLoggerFactory('videos')
@@ -67,6 +68,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
       // Refresh video since thumbnails to prevent concurrent updates
       const video = await VideoModel.loadFull(videoFromReq.id, t)
 
+      const oldDescription = video.description
       const oldVideoChannel = video.VideoChannel
 
       const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes<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,
diff --git a/server/server/controllers/api/videos/upload.ts b/server/server/controllers/api/videos/upload.ts
index 47f06e336..3d87deb1b 100644
--- a/server/server/controllers/api/videos/upload.ts
+++ b/server/server/controllers/api/videos/upload.ts
@@ -34,6 +34,8 @@ import {
 } from '../../../middlewares/index.js'
 import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
 import { VideoModel } from '../../../models/video/video.js'
+import { getChaptersFromContainer } from '@peertube/peertube-ffmpeg'
+import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
 
 const lTags = loggerTagsFactory('api', 'video')
 const auditLogger = auditLoggerFactory('videos')
@@ -143,6 +145,9 @@ async function addVideo (options: {
   const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' })
   const originalFilename = videoPhysicalFile.originalname
 
+  const containerChapters = await getChaptersFromContainer(videoPhysicalFile.path)
+  logger.debug(`Got ${containerChapters.length} chapters from video "${video.name}" container`, { containerChapters, ...lTags(video.uuid) })
+
   // Move physical file
   const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
   await move(videoPhysicalFile.path, destination)
@@ -188,6 +193,10 @@ async function addVideo (options: {
       }, sequelizeOptions)
     }
 
+    if (!await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction: t })) {
+      await replaceChapters({ video, chapters: containerChapters, transaction: t })
+    }
+
     await autoBlacklistVideoIfNeeded({
       video,
       user,
diff --git a/server/server/helpers/activity-pub-utils.ts b/server/server/helpers/activity-pub-utils.ts
index acc5c304b..cda40fdaa 100644
--- a/server/server/helpers/activity-pub-utils.ts
+++ b/server/server/helpers/activity-pub-utils.ts
@@ -79,6 +79,8 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
 
     uploadDate: 'sc:uploadDate',
 
+    hasParts: 'sc:hasParts',
+
     views: {
       '@type': 'sc:Number',
       '@id': 'pt:views'
@@ -195,7 +197,14 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
   Announce: buildContext(),
   Comment: buildContext(),
   Delete: buildContext(),
-  Rate: buildContext()
+  Rate: buildContext(),
+
+  Chapters: buildContext({
+    name: 'sc:name',
+    hasPart: 'sc:hasPart',
+    endOffset: 'sc:endOffset',
+    startOffset: 'sc:startOffset'
+  })
 }
 
 async function getContextData (type: ContextType, contextFilter: ContextFilter) {
diff --git a/server/server/helpers/custom-validators/activitypub/video-chapters.ts b/server/server/helpers/custom-validators/activitypub/video-chapters.ts
new file mode 100644
index 000000000..38009991b
--- /dev/null
+++ b/server/server/helpers/custom-validators/activitypub/video-chapters.ts
@@ -0,0 +1,15 @@
+import { isArray } from '../misc.js'
+import { isVideoChapterTitleValid, isVideoChapterTimecodeValid } from '../video-chapters.js'
+import { isActivityPubUrlValid } from './misc.js'
+import { VideoChaptersObject } from '@peertube/peertube-models'
+
+export function isVideoChaptersObjectValid (object: VideoChaptersObject) {
+  if (!object) return false
+  if (!isActivityPubUrlValid(object.id)) return false
+
+  if (!isArray(object.hasPart)) return false
+
+  return object.hasPart.every(part => {
+    return isVideoChapterTitleValid(part.name) && isVideoChapterTimecodeValid(part.startOffset)
+  })
+}
diff --git a/server/server/helpers/custom-validators/video-chapters.ts b/server/server/helpers/custom-validators/video-chapters.ts
new file mode 100644
index 000000000..8bdd2d7b8
--- /dev/null
+++ b/server/server/helpers/custom-validators/video-chapters.ts
@@ -0,0 +1,26 @@
+import { isArray } from './misc.js'
+import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models'
+import { Unpacked } from '@peertube/peertube-typescript-utils'
+import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
+import validator from 'validator'
+
+export function areVideoChaptersValid (value: VideoChapter[]) {
+  if (!isArray(value)) return false
+  if (!value.every(v => isVideoChapterValid(v))) return false
+
+  const timecodes = value.map(c => c.timecode)
+
+  return new Set(timecodes).size === timecodes.length
+}
+
+export function isVideoChapterValid (value: Unpacked<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 })
+}
diff --git a/server/server/helpers/youtube-dl/youtube-dl-info-builder.ts b/server/server/helpers/youtube-dl/youtube-dl-info-builder.ts
index 0287f6183..66993d2ee 100644
--- a/server/server/helpers/youtube-dl/youtube-dl-info-builder.ts
+++ b/server/server/helpers/youtube-dl/youtube-dl-info-builder.ts
@@ -1,6 +1,7 @@
 import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js'
 import { peertubeTruncate } from '../core-utils.js'
 import { isUrlValid } from '../custom-validators/activitypub/misc.js'
+import { isArray } from '../custom-validators/misc.js'
 
 export type YoutubeDLInfo = {
   name?: string
@@ -16,6 +17,11 @@ export type YoutubeDLInfo = {
   webpageUrl?: string
 
   urls?: string[]
+
+  chapters?: {
+    timecode: number
+    title: string
+  }[]
 }
 
 export class YoutubeDLInfoBuilder {
@@ -83,7 +89,10 @@ export class YoutubeDLInfoBuilder {
       urls: this.buildAvailableUrl(obj),
       originallyPublishedAtWithoutTime: this.buildOriginallyPublishedAt(obj),
       ext: obj.ext,
-      webpageUrl: obj.webpage_url
+      webpageUrl: obj.webpage_url,
+      chapters: isArray(obj.chapters)
+        ? obj.chapters.map((c: { start_time: number, title: string }) => ({ timecode: c.start_time, title: c.title }))
+        : []
     }
   }
 
diff --git a/server/server/initializers/constants.ts b/server/server/initializers/constants.ts
index 34392dbc8..027b927c2 100644
--- a/server/server/initializers/constants.ts
+++ b/server/server/initializers/constants.ts
@@ -465,6 +465,9 @@ const CONSTRAINTS_FIELDS = {
   },
   VIDEO_PASSWORD: {
     LENGTH: { min: 2, max: 100 }
+  },
+  VIDEO_CHAPTERS: {
+    TITLE: { min: 1, max: 100 } // Length
   }
 }
 
diff --git a/server/server/initializers/database.ts b/server/server/initializers/database.ts
index fe399a633..0294e2d29 100644
--- a/server/server/initializers/database.ts
+++ b/server/server/initializers/database.ts
@@ -59,6 +59,7 @@ import { VideoTagModel } from '../models/video/video-tag.js'
 import { VideoModel } from '../models/video/video.js'
 import { VideoViewModel } from '../models/view/video-view.js'
 import { CONFIG } from './config.js'
+import { VideoChapterModel } from '@server/models/video/video-chapter.js'
 
 pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string
 
@@ -137,6 +138,7 @@ async function initDatabaseModels (silent: boolean) {
     VideoShareModel,
     VideoFileModel,
     VideoSourceModel,
+    VideoChapterModel,
     VideoCaptionModel,
     VideoBlacklistModel,
     VideoTagModel,
diff --git a/server/server/lib/activitypub/url.ts b/server/server/lib/activitypub/url.ts
index 73f6f4849..aff104804 100644
--- a/server/server/lib/activitypub/url.ts
+++ b/server/server/lib/activitypub/url.ts
@@ -80,6 +80,10 @@ function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) {
   return video.url + '/comments'
 }
 
+function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
+  return video.url + '/chapters'
+}
+
 function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
   return video.url + '/likes'
 }
@@ -167,6 +171,7 @@ export {
   getDeleteActivityPubUrl,
   getLocalVideoSharesActivityPubUrl,
   getLocalVideoCommentsActivityPubUrl,
+  getLocalVideoChaptersActivityPubUrl,
   getLocalVideoLikesActivityPubUrl,
   getLocalVideoDislikesActivityPubUrl,
   getLocalVideoViewerActivityPubUrl,
diff --git a/server/server/lib/activitypub/videos/shared/abstract-builder.ts b/server/server/lib/activitypub/videos/shared/abstract-builder.ts
index 4397e578f..2c0ad99ac 100644
--- a/server/server/lib/activitypub/videos/shared/abstract-builder.ts
+++ b/server/server/lib/activitypub/videos/shared/abstract-builder.ts
@@ -1,6 +1,12 @@
 import { CreationAttributes, Transaction } from 'sequelize'
-import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType_Type } from '@peertube/peertube-models'
-import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils.js'
+import {
+  ActivityTagObject,
+  ThumbnailType,
+  VideoChaptersObject,
+  VideoObject,
+  VideoStreamingPlaylistType_Type
+} from '@peertube/peertube-models'
+import { deleteAllModels, filterNonExistingModels, retryTransactionWrapper } from '@server/helpers/database-utils.js'
 import { logger, LoggerTagsFn } from '@server/helpers/logger.js'
 import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js'
 import { setVideoTags } from '@server/lib/video.js'
@@ -29,6 +35,10 @@ import {
   getThumbnailFromIcons
 } from './object-to-model-attributes.js'
 import { getTrackerUrls, setVideoTrackers } from './trackers.js'
+import { fetchAP } from '../../activity.js'
+import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js'
+import { sequelizeTypescript } from '@server/initializers/database.js'
+import { replaceChapters } from '@server/lib/video-chapters.js'
 
 export abstract class APVideoAbstractBuilder {
   protected abstract videoObject: VideoObject
@@ -44,7 +54,7 @@ export abstract class APVideoAbstractBuilder {
   protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) {
     const miniatureIcon = getThumbnailFromIcons(this.videoObject)
     if (!miniatureIcon) {
-      logger.warn('Cannot find thumbnail in video object', { object: this.videoObject })
+      logger.warn('Cannot find thumbnail in video object', { object: this.videoObject, ...this.lTags() })
       return undefined
     }
 
@@ -138,6 +148,26 @@ export abstract class APVideoAbstractBuilder {
     video.VideoFiles = await Promise.all(upsertTasks)
   }
 
+  protected async updateChaptersOutsideTransaction (video: MVideoFullLight) {
+    if (!this.videoObject.hasParts || typeof this.videoObject.hasParts !== 'string') return
+
+    const { body } = await fetchAP<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))
diff --git a/server/server/lib/activitypub/videos/shared/creator.ts b/server/server/lib/activitypub/videos/shared/creator.ts
index 5a3a46282..35e537ccc 100644
--- a/server/server/lib/activitypub/videos/shared/creator.ts
+++ b/server/server/lib/activitypub/videos/shared/creator.ts
@@ -60,6 +60,8 @@ export class APVideoCreator extends APVideoAbstractBuilder {
       return { autoBlacklisted, videoCreated }
     })
 
+    await this.updateChaptersOutsideTransaction(videoCreated)
+
     return { autoBlacklisted, videoCreated }
   }
 }
diff --git a/server/server/lib/activitypub/videos/updater.ts b/server/server/lib/activitypub/videos/updater.ts
index 37bf7411a..f9c5b4040 100644
--- a/server/server/lib/activitypub/videos/updater.ts
+++ b/server/server/lib/activitypub/videos/updater.ts
@@ -77,6 +77,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
 
       await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
 
+      await this.updateChaptersOutsideTransaction(videoUpdated)
+
       await autoBlacklistVideoIfNeeded({
         video: videoUpdated,
         user: undefined,
diff --git a/server/server/lib/internal-event-emitter.ts b/server/server/lib/internal-event-emitter.ts
index 54f192982..db6e674d0 100644
--- a/server/server/lib/internal-event-emitter.ts
+++ b/server/server/lib/internal-event-emitter.ts
@@ -1,4 +1,4 @@
-import { MChannel, MVideo } from '@server/types/models/index.js'
+import { MChannel, MVideo, MVideoImmutable } from '@server/types/models/index.js'
 import { EventEmitter } from 'events'
 
 export interface PeerTubeInternalEvents {
@@ -9,6 +9,8 @@ export interface PeerTubeInternalEvents {
   'channel-created': (options: { channel: MChannel }) => void
   'channel-updated': (options: { channel: MChannel }) => void
   'channel-deleted': (options: { channel: MChannel }) => void
+
+  'chapters-updated': (options: { video: MVideoImmutable }) => void
 }
 
 declare interface InternalEventEmitter {
diff --git a/server/server/lib/job-queue/handlers/video-import.ts b/server/server/lib/job-queue/handlers/video-import.ts
index 7d5435a3b..09d974e90 100644
--- a/server/server/lib/job-queue/handlers/video-import.ts
+++ b/server/server/lib/job-queue/handlers/video-import.ts
@@ -32,6 +32,7 @@ import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImpo
 import { getLowercaseExtension } from '@peertube/peertube-node-utils'
 import {
   ffprobePromise,
+  getChaptersFromContainer,
   getVideoStreamDimensionsInfo,
   getVideoStreamDuration,
   getVideoStreamFPS,
@@ -49,6 +50,7 @@ import { federateVideoIfNeeded } from '../../activitypub/videos/index.js'
 import { Notifier } from '../../notifier/index.js'
 import { generateLocalVideoMiniature } from '../../thumbnail.js'
 import { JobQueue } from '../job-queue.js'
+import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js'
 
 async function processVideoImport (job: Job): Promise<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)
diff --git a/server/server/lib/video-chapters.ts b/server/server/lib/video-chapters.ts
new file mode 100644
index 000000000..c2b091356
--- /dev/null
+++ b/server/server/lib/video-chapters.ts
@@ -0,0 +1,99 @@
+import { parseChapters, sortBy } from '@peertube/peertube-core-utils'
+import { VideoChapter } from '@peertube/peertube-models'
+import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
+import { VideoChapterModel } from '@server/models/video/video-chapter.js'
+import { MVideoImmutable } from '@server/types/models/index.js'
+import { Transaction } from 'sequelize'
+import { InternalEventEmitter } from './internal-event-emitter.js'
+
+const lTags = loggerTagsFactory('video', 'chapters')
+
+export async function replaceChapters (options: {
+  video: MVideoImmutable
+  chapters: VideoChapter[]
+  transaction: Transaction
+}) {
+  const { chapters, transaction, video } = options
+
+  await VideoChapterModel.deleteChapters(video.id, transaction)
+
+  await createChapters({ videoId: video.id, chapters, transaction })
+
+  InternalEventEmitter.Instance.emit('chapters-updated', { video })
+}
+
+export async function replaceChaptersIfNotExist (options: {
+  video: MVideoImmutable
+  chapters: VideoChapter[]
+  transaction: Transaction
+}) {
+  const { chapters, transaction, video } = options
+
+  if (await VideoChapterModel.hasVideoChapters(video.id, transaction)) return
+
+  await createChapters({ videoId: video.id, chapters, transaction })
+
+  InternalEventEmitter.Instance.emit('chapters-updated', { video })
+}
+
+export async function replaceChaptersFromDescriptionIfNeeded (options: {
+  oldDescription?: string
+  newDescription: string
+  video: MVideoImmutable
+  transaction: Transaction
+}) {
+  const { transaction, video, newDescription, oldDescription = '' } = options
+
+  const chaptersFromOldDescription = sortBy(parseChapters(oldDescription), 'timecode')
+  const existingChapters = await VideoChapterModel.listChaptersOfVideo(video.id, transaction)
+
+  logger.debug(
+    'Check if we replace chapters from description',
+    { oldDescription, chaptersFromOldDescription, newDescription, existingChapters, ...lTags(video.uuid) }
+  )
+
+  // Then we can update chapters from the new description
+  if (areSameChapters(chaptersFromOldDescription, existingChapters)) {
+    const chaptersFromNewDescription = sortBy(parseChapters(newDescription), 'timecode')
+    if (chaptersFromOldDescription.length === 0 && chaptersFromNewDescription.length === 0) return false
+
+    await replaceChapters({ video, chapters: chaptersFromNewDescription, transaction })
+
+    logger.info('Replaced chapters of video ' + video.uuid, { chaptersFromNewDescription, ...lTags(video.uuid) })
+
+    return true
+  }
+
+  return false
+}
+
+// ---------------------------------------------------------------------------
+// Private
+// ---------------------------------------------------------------------------
+
+async function createChapters (options: {
+  videoId: number
+  chapters: VideoChapter[]
+  transaction: Transaction
+}) {
+  const { chapters, transaction, videoId } = options
+
+  for (const chapter of chapters) {
+    await VideoChapterModel.create({
+      title: chapter.title,
+      timecode: chapter.timecode,
+      videoId
+    }, { transaction })
+  }
+}
+
+function areSameChapters (chapters1: VideoChapter[], chapters2: VideoChapter[]) {
+  if (chapters1.length !== chapters2.length) return false
+
+  for (let i = 0; i < chapters1.length; i++) {
+    if (chapters1[i].timecode !== chapters2[i].timecode) return false
+    if (chapters1[i].title !== chapters2[i].title) return false
+  }
+
+  return true
+}
diff --git a/server/server/lib/video-pre-import.ts b/server/server/lib/video-pre-import.ts
index 0298e121e..447ea341d 100644
--- a/server/server/lib/video-pre-import.ts
+++ b/server/server/lib/video-pre-import.ts
@@ -39,6 +39,7 @@ import {
 } from '@server/types/models/index.js'
 import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
 import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js'
+import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js'
 
 class YoutubeDlImportError extends Error {
   code: YoutubeDlImportError.CODE
@@ -227,6 +228,29 @@ async function buildYoutubeDLImport (options: {
     videoPasswords: importDataOverride.videoPasswords
   })
 
+  await sequelizeTypescript.transaction(async transaction => {
+    // Priority to explicitely set description
+    if (importDataOverride?.description) {
+      const inserted = await replaceChaptersFromDescriptionIfNeeded({ newDescription: importDataOverride.description, video, transaction })
+      if (inserted) return
+    }
+
+    // Then priority to youtube-dl chapters
+    if (youtubeDLInfo.chapters.length !== 0) {
+      logger.info(
+        `Inserting chapters in video ${video.uuid} from youtube-dl`,
+        { chapters: youtubeDLInfo.chapters, tags: [ 'chapters', video.uuid ] }
+      )
+
+      await replaceChapters({ video, chapters: youtubeDLInfo.chapters, transaction })
+      return
+    }
+
+    if (video.description) {
+      await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction })
+    }
+  })
+
   // Get video subtitles
   await processYoutubeSubtitles(youtubeDL, targetUrl, video.id)
 
diff --git a/server/server/middlewares/cache/cache.ts b/server/server/middlewares/cache/cache.ts
index 6cf37e322..e615fc353 100644
--- a/server/server/middlewares/cache/cache.ts
+++ b/server/server/middlewares/cache/cache.ts
@@ -1,3 +1,4 @@
+import express from 'express'
 import { HttpStatusCode } from '@peertube/peertube-models'
 import { ApiCache, APICacheOptions } from './shared/index.js'
 
@@ -8,13 +9,13 @@ const defaultOptions: APICacheOptions = {
   ]
 }
 
-function cacheRoute (duration: string) {
+export function cacheRoute (duration: string) {
   const instance = new ApiCache(defaultOptions)
 
   return instance.buildMiddleware(duration)
 }
 
-function cacheRouteFactory (options: APICacheOptions) {
+export function cacheRouteFactory (options: APICacheOptions = {}) {
   const instance = new ApiCache({ ...defaultOptions, ...options })
 
   return { instance, middleware: instance.buildMiddleware.bind(instance) }
@@ -22,17 +23,36 @@ function cacheRouteFactory (options: APICacheOptions) {
 
 // ---------------------------------------------------------------------------
 
-function buildPodcastGroupsCache (options: {
+export function buildPodcastGroupsCache (options: {
   channelId: number
 }) {
   return 'podcast-feed-' + options.channelId
 }
 
+export function buildAPVideoChaptersGroupsCache (options: {
+  videoId: number | string
+}) {
+  return 'ap-video-chapters-' + options.videoId
+}
+
 // ---------------------------------------------------------------------------
 
-export {
-  cacheRoute,
-  cacheRouteFactory,
+export const videoFeedsPodcastSetCacheKey = [
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (req.query.videoChannelId) {
+      res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
+    }
 
-  buildPodcastGroupsCache
-}
+    return next()
+  }
+]
+
+export const apVideoChaptersSetCacheKey = [
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (req.params.id) {
+      res.locals.apicacheGroups = [ buildAPVideoChaptersGroupsCache({ videoId: req.params.id }) ]
+    }
+
+    return next()
+  }
+]
diff --git a/server/server/middlewares/validators/feeds.ts b/server/server/middlewares/validators/feeds.ts
index ec99b6920..895dd35ba 100644
--- a/server/server/middlewares/validators/feeds.ts
+++ b/server/server/middlewares/validators/feeds.ts
@@ -3,7 +3,6 @@ import { param, query } from 'express-validator'
 import { HttpStatusCode } from '@peertube/peertube-models'
 import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js'
 import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js'
-import { buildPodcastGroupsCache } from '../cache/index.js'
 import {
   areValidationErrors,
   checkCanSeeVideo,
@@ -114,15 +113,6 @@ const videoFeedsPodcastValidator = [
   }
 ]
 
-const videoFeedsPodcastSetCacheKey = [
-  (req: express.Request, res: express.Response, next: express.NextFunction) => {
-    if (req.query.videoChannelId) {
-      res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
-    }
-
-    return next()
-  }
-]
 // ---------------------------------------------------------------------------
 
 const videoSubscriptionFeedsValidator = [
@@ -173,6 +163,5 @@ export {
   feedsAccountOrChannelFiltersValidator,
   videoFeedsPodcastValidator,
   videoSubscriptionFeedsValidator,
-  videoFeedsPodcastSetCacheKey,
   videoCommentsFeedsValidator
 }
diff --git a/server/server/middlewares/validators/videos/index.ts b/server/server/middlewares/validators/videos/index.ts
index 05c6659ae..eed4f35d4 100644
--- a/server/server/middlewares/validators/videos/index.ts
+++ b/server/server/middlewares/validators/videos/index.ts
@@ -2,6 +2,7 @@ export * from './video-blacklist.js'
 export * from './video-captions.js'
 export * from './video-channel-sync.js'
 export * from './video-channels.js'
+export * from './video-chapters.js'
 export * from './video-comments.js'
 export * from './video-files.js'
 export * from './video-imports.js'
diff --git a/server/server/middlewares/validators/videos/video-chapters.ts b/server/server/middlewares/validators/videos/video-chapters.ts
new file mode 100644
index 000000000..5097e6380
--- /dev/null
+++ b/server/server/middlewares/validators/videos/video-chapters.ts
@@ -0,0 +1,34 @@
+import express from 'express'
+import { body } from 'express-validator'
+import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
+import {
+  areValidationErrors, checkUserCanManageVideo, doesVideoExist,
+  isValidVideoIdParam
+} from '../shared/index.js'
+import { areVideoChaptersValid } from '@server/helpers/custom-validators/video-chapters.js'
+
+export const updateVideoChaptersValidator = [
+  isValidVideoIdParam('videoId'),
+
+  body('chapters')
+    .custom(areVideoChaptersValid)
+    .withMessage('Chapters must have a valid title and timecode, and each timecode must be unique'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+    if (!await doesVideoExist(req.params.videoId, res)) return
+
+    if (res.locals.videoAll.isLive) {
+      return res.fail({
+        status: HttpStatusCode.BAD_REQUEST_400,
+        message: 'You cannot add chapters to a live video'
+      })
+    }
+
+    // Check if the user who did the request is able to update video chapters (same right as updating the video)
+    const user = res.locals.oauth.token.User
+    if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
+
+    return next()
+  }
+]
diff --git a/server/server/models/video/formatter/video-activity-pub-format.ts b/server/server/models/video/formatter/video-activity-pub-format.ts
index 759e6dbbc..d19bb1880 100644
--- a/server/server/models/video/formatter/video-activity-pub-format.ts
+++ b/server/server/models/video/formatter/video-activity-pub-format.ts
@@ -13,6 +13,7 @@ import {
 } from '@peertube/peertube-models'
 import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js'
 import {
+  getLocalVideoChaptersActivityPubUrl,
   getLocalVideoCommentsActivityPubUrl,
   getLocalVideoDislikesActivityPubUrl,
   getLocalVideoLikesActivityPubUrl,
@@ -95,6 +96,7 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
     dislikes: getLocalVideoDislikesActivityPubUrl(video),
     shares: getLocalVideoSharesActivityPubUrl(video),
     comments: getLocalVideoCommentsActivityPubUrl(video),
+    hasParts: getLocalVideoChaptersActivityPubUrl(video),
 
     attributedTo: [
       {
diff --git a/server/server/models/video/video-chapter.ts b/server/server/models/video/video-chapter.ts
new file mode 100644
index 000000000..6e59abec9
--- /dev/null
+++ b/server/server/models/video/video-chapter.ts
@@ -0,0 +1,95 @@
+import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
+import { MVideo, MVideoChapter } from '@server/types/models/index.js'
+import { VideoChapter, VideoChapterObject } from '@peertube/peertube-models'
+import { AttributesOnly } from '@peertube/peertube-typescript-utils'
+import { VideoModel } from './video.js'
+import { Transaction } from 'sequelize'
+import { getSort } from '../shared/sort.js'
+
+@Table({
+  tableName: 'videoChapter',
+  indexes: [
+    {
+      fields: [ 'videoId', 'timecode' ],
+      unique: true
+    }
+  ]
+})
+export class VideoChapterModel extends Model<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
+    }
+  }
+}
diff --git a/server/server/types/models/account/account.ts b/server/server/types/models/account/account.ts
index 4a5e80725..a8ff058ed 100644
--- a/server/server/types/models/account/account.ts
+++ b/server/server/types/models/account/account.ts
@@ -14,7 +14,7 @@ import {
   MActorSummaryFormattable,
   MActorUrl
 } from '../actor/index.js'
-import { MChannelDefault } from '../video/video-channels.js'
+import { MChannelDefault } from '../video/video-channel.js'
 import { MAccountBlocklistId } from './account-blocklist.js'
 
 type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M>
diff --git a/server/server/types/models/user/user.ts b/server/server/types/models/user/user.ts
index 4a655c792..3d0bee1aa 100644
--- a/server/server/types/models/user/user.ts
+++ b/server/server/types/models/user/user.ts
@@ -11,7 +11,7 @@ import {
   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>
diff --git a/server/server/types/models/video/index.ts b/server/server/types/models/video/index.ts
index f88198b67..0eeb7aad2 100644
--- a/server/server/types/models/video/index.ts
+++ b/server/server/types/models/video/index.ts
@@ -10,7 +10,8 @@ export * from './video-blacklist.js'
 export * from './video-caption.js'
 export * from './video-change-ownership.js'
 export * from './video-channel-sync.js'
-export * from './video-channels.js'
+export * from './video-channel.js'
+export * from './video-chapter.js'
 export * from './video-comment.js'
 export * from './video-file.js'
 export * from './video-import.js'
diff --git a/server/server/types/models/video/video-channel-sync.ts b/server/server/types/models/video/video-channel-sync.ts
index 2b3a3930f..7e4f9373b 100644
--- a/server/server/types/models/video/video-channel-sync.ts
+++ b/server/server/types/models/video/video-channel-sync.ts
@@ -1,6 +1,6 @@
 import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
 import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils'
-import { MChannelAccountDefault, MChannelFormattable } from './video-channels.js'
+import { MChannelAccountDefault, MChannelFormattable } from './video-channel.js'
 
 type Use<K extends keyof VideoChannelSyncModel, M> = PickWith<VideoChannelSyncModel, K, M>
 
diff --git a/server/server/types/models/video/video-channels.ts b/server/server/types/models/video/video-channel.ts
similarity index 100%
rename from server/server/types/models/video/video-channels.ts
rename to server/server/types/models/video/video-channel.ts
diff --git a/server/server/types/models/video/video-chapter.ts b/server/server/types/models/video/video-chapter.ts
new file mode 100644
index 000000000..377cf213a
--- /dev/null
+++ b/server/server/types/models/video/video-chapter.ts
@@ -0,0 +1,3 @@
+import { VideoChapterModel } from '@server/models/video/video-chapter.js'
+
+export type MVideoChapter = Omit<VideoChapterModel, 'Video'>
diff --git a/server/server/types/models/video/video-playlist.ts b/server/server/types/models/video/video-playlist.ts
index 3d99bf4e5..152904d22 100644
--- a/server/server/types/models/video/video-playlist.ts
+++ b/server/server/types/models/video/video-playlist.ts
@@ -3,7 +3,7 @@ import { PickWith } from '@peertube/peertube-typescript-utils'
 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>
 
diff --git a/server/server/types/models/video/video.ts b/server/server/types/models/video/video.ts
index b7f8652be..f9141681b 100644
--- a/server/server/types/models/video/video.ts
+++ b/server/server/types/models/video/video.ts
@@ -16,7 +16,7 @@ import {
   MChannelFormattable,
   MChannelHostOnly,
   MChannelUserId
-} from './video-channels.js'
+} from './video-channel.js'
 import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js'
 import { MVideoLive } from './video-live.js'
 import {
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index 8d85f9c77..e3931a36e 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -257,6 +257,8 @@ tags:
     description: Operations dealing with synchronizing PeerTube user's channel with channels of other platforms
   - name: Video Captions
     description: Operations dealing with listing, adding and removing closed captions of a video.
+  - name: Video Chapters
+    description: Operations dealing with managing chapters of a video.
   - name: Video Channels
     description: Operations dealing with the creation, modification and listing of videos within a channel.
   - name: Video Comments
@@ -328,6 +330,7 @@ x-tagGroups:
       - Video Upload
       - Video Imports
       - Video Captions
+      - Video Chapters
       - Video Channels
       - Video Comments
       - Video Rates
@@ -3242,7 +3245,7 @@ paths:
   '/api/v1/videos/{id}/source/replace-resumable':
     post:
       summary: Initialize the resumable replacement of a video
-      description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to initialize the replacement of a video
+      description: "**PeerTube >= 6.0** Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to initialize the replacement of a video"
       operationId: replaceVideoSourceResumableInit
       security:
         - OAuth2: []
@@ -3281,7 +3284,7 @@ paths:
           description: video type unsupported
     put:
       summary: Send chunk for the resumable replacement of a video
-      description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to continue, pause or resume the replacement of a video
+      description: "**PeerTube >= 6.0** Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to continue, pause or resume the replacement of a video"
       operationId: replaceVideoSourceResumable
       security:
         - OAuth2: []
@@ -3331,7 +3334,7 @@ paths:
                 example: 300
     delete:
       summary: Cancel the resumable replacement of a video
-      description: Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to cancel the replacement of a video
+      description: "**PeerTube >= 6.0** Uses [a resumable protocol](https://github.com/kukhariev/node-uploadx/blob/master/proto.md) to cancel the replacement of a video"
       operationId: replaceVideoSourceResumableCancel
       security:
         - OAuth2: []
@@ -3742,6 +3745,7 @@ paths:
   /api/v1/videos/{id}/storyboards:
     get:
       summary: List storyboards of a video
+      description: "**PeerTube** >= 6.0"
       operationId: listVideoStoryboards
       tags:
         - Video
@@ -3832,9 +3836,59 @@ paths:
         '404':
           description: video or language or caption for that language not found
 
+  /api/v1/videos/{id}/chapters:
+    get:
+      summary: Get chapters of a video
+      description: "**PeerTube** >= 6.0"
+      operationId: getVideoChapters
+      tags:
+        - Video Chapters
+      parameters:
+        - $ref: '#/components/parameters/idOrUUID'
+        - $ref: '#/components/parameters/videoPasswordHeader'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/VideoChapters'
+    put:
+      summary: Replace video chapters
+      description: "**PeerTube** >= 6.0"
+      operationId: replaceVideoChapters
+      security:
+        - OAuth2:
+          - user
+      tags:
+        - Video Chapters
+      parameters:
+        - $ref: '#/components/parameters/idOrUUID'
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                chapters:
+                  type: array
+                  items:
+                    type: object
+                    properties:
+                      title:
+                        type: string
+                      timecode:
+                        type: integer
+      responses:
+        '204':
+          description: successful operation
+        '404':
+          description: video not found
+
   /api/v1/videos/{id}/passwords:
     get:
       summary: List video passwords
+      description: "**PeerTube** >= 6.0"
       security:
         - OAuth2:
           - user
@@ -3856,6 +3910,7 @@ paths:
           description: video is not password protected
     put:
       summary: Update video passwords
+      description: "**PeerTube** >= 6.0"
       security:
         - OAuth2:
           - user
@@ -3880,6 +3935,7 @@ paths:
   /api/v1/videos/{id}/passwords/{videoPasswordId}:
     delete:
       summary: Delete a video password
+      description: "**PeerTube** >= 6.0"
       security:
         - OAuth2:
           - user
@@ -7704,6 +7760,15 @@ components:
           $ref: '#/components/schemas/VideoConstantString-Language'
         captionPath:
           type: string
+    VideoChapters:
+      properties:
+        chapters:
+          type: object
+          properties:
+            title:
+              type: string
+            timecode:
+              type: integer
     VideoSource:
       properties:
         filename: