Add video chapters support

pull/5960/head
Chocobozzz 2023-08-28 10:55:04 +02:00
parent 7113f32a87
commit 77b70702d2
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
101 changed files with 1957 additions and 158 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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