</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>
</ng-container>
-
<ng-container ngbNavItem>
<a ngbNavLink i18n>Advanced settings</a>
@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;
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,
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,
LiveVideoLatencyMode,
RegisterClientFormFieldOptions,
RegisterClientVideoFieldOptions,
+ VideoChapter,
VideoConstant,
VideoDetails,
VideoPrivacy,
})
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
@Input() videoCaptions: VideoCaptionWithPathEdit[] = []
@Input() videoSource: VideoSource
+ @Input() videoChapters: VideoChapter[] = []
+
@Input() hideWaitTranscoding = false
@Input() updateVideoFileEnabled = false
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,
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()
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']
} else {
videoPasswordControl.clearValidators()
}
- videoPasswordControl.updateValueAndValidity()
+ videoPasswordControl.updateValueAndValidity()
}
)
}
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'
protected serverService: ServerService,
protected videoService: VideoService,
protected videoCaptionService: VideoCaptionService,
+ protected videoChapterService: VideoChapterService,
private liveVideoService: LiveVideoService,
private router: Router,
private hooks: HooksService
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 }
// Update the video
forkJoin([
- this.updateVideoAndCaptions(video),
+ this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions }),
this.liveVideoService.updateLive(this.videoId, liveVideoUpdate)
]).subscribe({
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'
protected serverService: ServerService,
protected videoService: VideoService,
protected videoCaptionService: VideoCaptionService,
+ protected videoChapterService: VideoChapterService,
private router: Router,
private videoImportService: VideoImportService,
private hooks: HooksService
if (!await this.isFormValid()) return
this.video.patch(this.form.value)
+ this.chaptersEdit.patch(this.form.value)
this.isUpdatingVideo = true
// Update the video
- this.updateVideoAndCaptions(this.video)
- .subscribe({
- next: () => {
- this.isUpdatingVideo = false
- this.notifier.success($localize`Video to import updated.`)
-
- this.router.navigate([ '/my-library', 'video-imports' ])
- },
-
- error: err => {
- this.error = err.message
- scrollToTop()
- logger.error(err)
- }
- })
+ this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit })
+ .subscribe({
+ next: () => {
+ this.isUpdatingVideo = false
+ this.notifier.success($localize`Video to import updated.`)
+
+ this.router.navigate([ '/my-library', 'video-imports' ])
+ },
+
+ error: err => {
+ this.error = err.message
+ scrollToTop()
+ logger.error(err)
+ }
+ })
}
}
<!-- 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"
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',
]
})
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>()
protected serverService: ServerService,
protected videoService: VideoService,
protected videoCaptionService: VideoCaptionService,
+ protected videoChapterService: VideoChapterService,
private router: Router,
private videoImportService: VideoImportService,
private hooks: HooksService
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
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 => {
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
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
userVideoChannels: SelectChannelItem[] = []
videoPrivacies: VideoConstant<VideoPrivacyType>[] = []
videoCaptions: VideoCaptionEdit[] = []
+ chaptersEdit = new VideoChaptersEdit()
firstStepPrivacyId: VideoPrivacyType
firstStepChannelId: number
protected serverService: ServerService
protected videoService: VideoService
protected videoCaptionService: VideoCaptionService
+ protected videoChapterService: VideoChapterService
protected serverConfig: HTMLServerConfig
})
}
- 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()
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'
protected serverService: ServerService,
protected videoService: VideoService,
protected videoCaptionService: VideoCaptionService,
+ protected videoChapterService: VideoChapterService,
private userService: UserService,
private router: Router,
private hooks: HooksService,
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
<form novalidate [formGroup]="form">
<my-video-edit
+ #videoEdit
[form]="form" [formErrors]="formErrors" [forbidScheduledPublication]="forbidScheduledPublication"
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
[videoCaptions]="videoCaptions" [hideWaitTranscoding]="isWaitTranscodingHidden()"
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')
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
private uploadServiceSubscription: Subscription
private updateSubcription: Subscription
+ private chaptersEdit = new VideoChaptersEdit()
+
constructor (
protected formReactiveService: FormReactiveService,
private route: ActivatedRoute,
private videoService: VideoService,
private loadingBar: LoadingBarService,
private videoCaptionService: VideoCaptionService,
+ private videoChapterService: VideoChapterService,
private server: ServerService,
private liveVideoService: LiveVideoService,
private videoUploadService: VideoUploadService,
.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
onFormBuilt () {
hydrateFormFromVideo(this.form, this.videoEdit, true)
+ setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit))
+
if (this.liveVideo) {
this.form.patchValue({
saveReplay: this.liveVideo.saveReplay,
if (!await this.checkAndConfirmVideoFileReplacement()) return
this.videoEdit.patch(this.form.value)
+ this.chaptersEdit.patch(this.form.value)
this.abortUpdateIfNeeded()
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)
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'
private liveVideoService: LiveVideoService,
private authService: AuthService,
private videoCaptionService: VideoCaptionService,
+ private videoChapterService: VideoChapterService,
private videoPasswordService: VideoPasswordService
) {
}
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 }))
)
}
map(result => result.data)
),
+ this.videoChapterService
+ .getChapters({ videoId: video.uuid })
+ .pipe(
+ map(({ chapters }) => chapters)
+ ),
+
video.isLive
? this.liveVideoService.getVideoLive(video.id)
: of(undefined),
} 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'
ServerErrorCode,
Storyboard,
VideoCaption,
+ VideoChapter,
VideoPrivacy,
VideoState,
VideoStateType
video: VideoDetails = null
videoCaptions: VideoCaption[] = []
+ videoChapters: VideoChapter[] = []
liveVideo: LiveVideo
videoPassword: string
storyboards: Storyboard[] = []
private notifier: Notifier,
private zone: NgZone,
private videoCaptionService: VideoCaptionService,
+ private videoChapterService: VideoChapterService,
private hotkeysService: HotkeysService,
private hooks: HooksService,
private pluginService: PluginService,
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,
video: VideoDetails
live: LiveVideo
videoCaptions: VideoCaption[]
+ videoChapters: VideoChapter[]
storyboards: Storyboard[]
videoFileToken: string
videoPassword: string
video,
live,
videoCaptions,
+ videoChapters,
storyboards,
videoFileToken,
videoPassword,
this.video = video
this.videoCaptions = videoCaptions
+ this.videoChapters = videoChapters
this.liveVideo = live
this.videoFileToken = videoFileToken
this.videoPassword = videoPassword
const params = {
video: this.video,
videoCaptions: this.videoCaptions,
+ videoChapters: this.videoChapters,
storyboards: this.storyboards,
liveVideo: this.liveVideo,
videoFileToken: this.videoFileToken,
video: VideoDetails
liveVideo: LiveVideo
videoCaptions: VideoCaption[]
+ videoChapters: VideoChapter[]
storyboards: Storyboard[]
videoFileToken: string
video,
liveVideo,
videoCaptions,
+ videoChapters,
storyboards,
videoFileToken,
videoPassword,
videoPassword: () => videoPassword,
videoCaptions: playerCaptions,
+ videoChapters,
storyboard,
videoShortUUID: video.shortUUID,
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
}
export {
- sortBy,
immutableAssign,
removeElementFromArray,
splitIntoArray,
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',
--- /dev/null
+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
+ }
+}
}
}
-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: {
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()
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]
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
if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
- const defaultValue = defaultValues[name] || ''
+ const defaultValue = defaultValues[name] ?? ''
form.addControl(
name + '',
}
}
+ 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
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',
@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
</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>
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',
export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
@Input() content = ''
- @Input() formError: string
+ @Input() formError: string | FormReactiveErrors | FormReactiveErrors[]
@Input() truncateTo3Lines: boolean
p-inputmask {
::ng-deep input {
width: 80px;
+ text-align: center;
&:focus-within,
&:focus {
-<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>
import {
EmbedComponent,
RedundancyService,
+ VideoChapterService,
VideoFileTokenService,
VideoImportService,
VideoOwnershipService,
VideoPasswordService,
+ VideoChapterService,
+
CustomPageService,
ActorRedirectGuard
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'
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'
--- /dev/null
+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)))
+ }
+}
--- /dev/null
+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
+ }
+}
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'
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()
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)
}
--- /dev/null
+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 }
+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'
--- /dev/null
+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)
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
--- /dev/null
+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)
-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'
}
videoCaptions: VideoJSCaption[]
+ videoChapters: VideoChapter[]
storyboard: VideoJSStoryboard
videoUUID: string
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'
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' {
peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin
+ chapters (options?: ChaptersOptions): ChaptersPlugin
+
upnext (options?: UpNextPluginOptions): UpNextPlugin
playlist (options?: PlaylistPluginOptions): PlaylistPlugin
interval: number
}
+type ChaptersOptions = {
+ chapters: VideoChapter[]
+}
+
type PlaylistPluginOptions = {
elements: VideoPlaylistElement[]
isSuspended: () => boolean
}
+type ProgressBarMarkerComponentOptions = {
+ timecode: number
+}
+
type NextPreviousVideoButtonOptions = {
type: 'next' | 'previous'
handler?: () => void
NextPreviousVideoButtonOptions,
ResolutionUpdateData,
AutoResolutionUpdateData,
+ ProgressBarMarkerComponentOptions,
PlaylistPluginOptions,
MetricsPluginOptions,
VideoJSCaption,
UpNextPluginOptions,
LoadedQualityData,
StoryboardOptions,
+ ChaptersOptions,
PeerTubeLinkButtonOptions
}
@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;
}
}
}
+
+.vjs-marker {
+ position: absolute;
+ width: 3px;
+ opacity: .5;
+ background-color: #000;
+ height: 100%;
+ top: 0;
+}
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 })
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) => {
{ video, live, videoFileToken },
translations,
captionsResponse,
+ chaptersResponse,
storyboardsResponse
] = await Promise.all([
videoInfoPromise,
this.translationsPromise,
captionsPromise,
+ chaptersPromise,
storyboardsPromise,
this.buildPlayerIfNeeded()
])
const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({
video,
captionsResponse,
+ chaptersResponse,
translations,
storyboardsResponse,
Storyboard,
Video,
VideoCaption,
+ VideoChapter,
VideoDetails,
VideoPlaylistElement,
VideoState,
storyboardsResponse: Response
+ chaptersResponse: Response
+
live?: LiveVideo
alreadyPlayed: boolean
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 {
subtitle: this.subtitle,
storyboard,
+ videoChapters: chapters,
startTime: playlist
? playlist.playlistTracker.getCurrentElement().startTimestamp
}
}
+ private async buildChapters (chaptersResponse: Response) {
+ const { chapters } = await chaptersResponse.json() as { chapters: VideoChapter[] }
+
+ return chapters
+ }
+
private buildPlaylistOptions (options?: {
playlistTracker: PlaylistTracker
playNext: () => any
}
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) {
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
}
-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
}
// 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--) {
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
+ })
}
// ---------------------------------------------------------------------------
+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
export * from './renderer/index.js'
export * from './users/index.js'
export * from './videos/index.js'
+export * from './string/index.js'
--- /dev/null
+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
+}
--- /dev/null
+export * from './chapters.js'
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)
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,
'Flag' |
'Actor' |
'Collection' |
- 'WatchAction'
+ 'WatchAction' |
+ 'Chapters'
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'
--- /dev/null
+export interface VideoChaptersObject {
+ id: string
+ hasPart: VideoChapterObject[]
+}
+
+// Same as https://schema.org/hasPart
+export interface VideoChapterObject {
+ name: string
+ startOffset: number
+ endOffset: number
+}
dislikes: string
shares: string
comments: string
+ hasParts: string
attributedTo: ActivityPubAttributedTo[]
--- /dev/null
+export interface VideoChapterUpdate {
+ chapters: {
+ timecode: number
+ title: string
+ }[]
+}
--- /dev/null
+export interface VideoChapter {
+ timecode: number
+ title: string
+}
--- /dev/null
+export * from './chapter-update.model.js'
+export * from './chapter.model.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'
ChangeOwnershipCommand,
ChannelsCommand,
ChannelSyncsCommand,
+ ChaptersCommand,
CommentsCommand,
HistoryCommand,
ImportsCommand,
videoPasswords?: VideoPasswordsCommand
storyboard?: StoryboardCommand
+ chapters?: ChaptersCommand
runners?: RunnersCommand
runnerRegistrationTokens?: RunnerRegistrationTokensCommand
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)
--- /dev/null
+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
+ })
+ }
+}
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'
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'
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 () {
})
})
+ 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 = {
--- /dev/null
+/* 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 ])
+ })
+})
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'
--- /dev/null
+/* 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)
+ })
+})
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'
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' }
+ ])
+ })
+})
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,
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'
}
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
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'
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,
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())
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,
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
--- /dev/null
+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)
+}
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()
videosRouter.use('/', videoPasswordRouter)
videosRouter.use('/', storyboardRouter)
videosRouter.use('/', videoSourceRouter)
+videosRouter.use('/', videoChaptersRouter)
videosRouter.get('/categories',
openapiOperationDoc({ operationId: 'getCategories' }),
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')
// 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>)[] = [
// 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,
} 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')
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)
}, sequelizeOptions)
}
+ if (!await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction: t })) {
+ await replaceChapters({ video, chapters: containerChapters, transaction: t })
+ }
+
await autoBlacklistVideoIfNeeded({
video,
user,
uploadDate: 'sc:uploadDate',
+ hasParts: 'sc:hasParts',
+
views: {
'@type': 'sc:Number',
'@id': 'pt:views'
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) {
--- /dev/null
+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)
+ })
+}
--- /dev/null
+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 })
+}
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
webpageUrl?: string
urls?: string[]
+
+ chapters?: {
+ timecode: number
+ title: string
+ }[]
}
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 }))
+ : []
}
}
},
VIDEO_PASSWORD: {
LENGTH: { min: 2, max: 100 }
+ },
+ VIDEO_CHAPTERS: {
+ TITLE: { min: 1, max: 100 } // Length
}
}
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
VideoShareModel,
VideoFileModel,
VideoSourceModel,
+ VideoChapterModel,
VideoCaptionModel,
VideoBlacklistModel,
VideoTagModel,
return video.url + '/comments'
}
+function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
+ return video.url + '/chapters'
+}
+
function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
return video.url + '/likes'
}
getDeleteActivityPubUrl,
getLocalVideoSharesActivityPubUrl,
getLocalVideoCommentsActivityPubUrl,
+ getLocalVideoChaptersActivityPubUrl,
getLocalVideoLikesActivityPubUrl,
getLocalVideoDislikesActivityPubUrl,
getLocalVideoViewerActivityPubUrl,
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'
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
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
}
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))
return { autoBlacklisted, videoCreated }
})
+ await this.updateChaptersOutsideTransaction(videoCreated)
+
return { autoBlacklisted, videoCreated }
}
}
await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
+ await this.updateChaptersOutsideTransaction(videoUpdated)
+
await autoBlacklistVideoIfNeeded({
video: videoUpdated,
user: undefined,
-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 {
'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 {
import { getLowercaseExtension } from '@peertube/peertube-node-utils'
import {
ffprobePromise,
+ getChaptersFromContainer,
getVideoStreamDimensionsInfo,
getVideoStreamDuration,
getVideoStreamFPS,
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
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 = {
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)
--- /dev/null
+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
+}
} 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
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)
+import express from 'express'
import { HttpStatusCode } from '@peertube/peertube-models'
import { ApiCache, APICacheOptions } from './shared/index.js'
]
}
-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) }
// ---------------------------------------------------------------------------
-function buildPodcastGroupsCache (options: {
+export function buildPodcastGroupsCache (options: {
channelId: number
}) {
return 'podcast-feed-' + options.channelId
}
-// ---------------------------------------------------------------------------
+export function buildAPVideoChaptersGroupsCache (options: {
+ videoId: number | string
+}) {
+ return 'ap-video-chapters-' + options.videoId
+}
-export {
- cacheRoute,
- cacheRouteFactory,
+// ---------------------------------------------------------------------------
- buildPodcastGroupsCache
-}
+export const videoFeedsPodcastSetCacheKey = [
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (req.query.videoChannelId) {
+ res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
+ }
+
+ return next()
+ }
+]
+
+export const apVideoChaptersSetCacheKey = [
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (req.params.id) {
+ res.locals.apicacheGroups = [ buildAPVideoChaptersGroupsCache({ videoId: req.params.id }) ]
+ }
+
+ return next()
+ }
+]
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,
}
]
-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 = [
feedsAccountOrChannelFiltersValidator,
videoFeedsPodcastValidator,
videoSubscriptionFeedsValidator,
- videoFeedsPodcastSetCacheKey,
videoCommentsFeedsValidator
}
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'
--- /dev/null
+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()
+ }
+]
} from '@peertube/peertube-models'
import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js'
import {
+ getLocalVideoChaptersActivityPubUrl,
getLocalVideoCommentsActivityPubUrl,
getLocalVideoDislikesActivityPubUrl,
getLocalVideoLikesActivityPubUrl,
dislikes: getLocalVideoDislikesActivityPubUrl(video),
shares: getLocalVideoSharesActivityPubUrl(video),
comments: getLocalVideoCommentsActivityPubUrl(video),
+ hasParts: getLocalVideoChaptersActivityPubUrl(video),
attributedTo: [
{
--- /dev/null
+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
+ }
+ }
+}
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>
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>
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'
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>
--- /dev/null
+import { VideoChapterModel } from '@server/models/video/video-chapter.js'
+
+export type MVideoChapter = Omit<VideoChapterModel, 'Video'>
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>
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 {
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
- Video Upload
- Video Imports
- Video Captions
+ - Video Chapters
- Video Channels
- Video Comments
- Video Rates
'/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: []
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: []
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: []
/api/v1/videos/{id}/storyboards:
get:
summary: List storyboards of a video
+ description: "**PeerTube** >= 6.0"
operationId: listVideoStoryboards
tags:
- Video
'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
description: video is not password protected
put:
summary: Update video passwords
+ description: "**PeerTube** >= 6.0"
security:
- OAuth2:
- user
/api/v1/videos/{id}/passwords/{videoPasswordId}:
delete:
summary: Delete a video password
+ description: "**PeerTube** >= 6.0"
security:
- OAuth2:
- user
$ref: '#/components/schemas/VideoConstantString-Language'
captionPath:
type: string
+ VideoChapters:
+ properties:
+ chapters:
+ type: object
+ properties:
+ title:
+ type: string
+ timecode:
+ type: integer
VideoSource:
properties:
filename: