mirror of https://github.com/Chocobozzz/PeerTube
Add video chapters support
parent
7113f32a87
commit
77b70702d2
|
@ -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>
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()"
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
p-inputmask {
|
||||
::ng-deep input {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
|
||||
&:focus-within,
|
||||
&:focus {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { simpleObjectsDeepEqual, sortBy } from '@peertube/peertube-core-utils'
|
||||
import { VideoChapter } from '@peertube/peertube-models'
|
||||
|
||||
export class VideoChaptersEdit {
|
||||
private chaptersFromAPI: VideoChapter[] = []
|
||||
|
||||
private chapters: VideoChapter[]
|
||||
|
||||
loadFromAPI (chapters: VideoChapter[]) {
|
||||
this.chapters = chapters || []
|
||||
|
||||
this.chaptersFromAPI = chapters
|
||||
}
|
||||
|
||||
patch (values: { [ id: string ]: any }) {
|
||||
const chapters = values.chapters || []
|
||||
|
||||
this.chapters = chapters.map((c: any) => {
|
||||
return {
|
||||
timecode: c.timecode || 0,
|
||||
title: c.title
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
toFormPatch () {
|
||||
return { chapters: this.chapters }
|
||||
}
|
||||
|
||||
getChaptersForUpdate (): VideoChapter[] {
|
||||
return this.chapters.filter(c => !!c.title)
|
||||
}
|
||||
|
||||
hasDuplicateValues () {
|
||||
const timecodes = this.chapters.map(c => c.timecode)
|
||||
|
||||
return new Set(timecodes).size !== this.chapters.length
|
||||
}
|
||||
|
||||
shouldUpdateAPI () {
|
||||
return simpleObjectsDeepEqual(sortBy(this.getChaptersForUpdate(), 'timecode'), this.chaptersFromAPI) !== true
|
||||
}
|
||||
}
|
|
@ -7,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)
|
||||
}
|
||||
|
|
|
@ -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 }
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from './chapters.js'
|
|
@ -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,
|
||||
|
|
|
@ -13,4 +13,5 @@ export type ContextType =
|
|||
'Flag' |
|
||||
'Actor' |
|
||||
'Collection' |
|
||||
'WatchAction'
|
||||
'WatchAction' |
|
||||
'Chapters'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -50,6 +50,7 @@ export interface VideoObject {
|
|||
dislikes: string
|
||||
shares: string
|
||||
comments: string
|
||||
hasParts: string
|
||||
|
||||
attributedTo: ActivityPubAttributedTo[]
|
||||
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
export interface VideoChapterUpdate {
|
||||
chapters: {
|
||||
timecode: number
|
||||
title: string
|
||||
}[]
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export interface VideoChapter {
|
||||
timecode: number
|
||||
title: string
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from './chapter-update.model.js'
|
||||
export * from './chapter.model.js'
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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.
|
@ -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'
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 ])
|
||||
})
|
||||
})
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -3,7 +3,7 @@
|
|||
import { expect } from 'chai'
|
||||
import snakeCase from 'lodash-es/snakeCase.js'
|
||||
import validator from 'validator'
|
||||
import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate } from '@peertube/peertube-core-utils'
|
||||
import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, parseChapters } from '@peertube/peertube-core-utils'
|
||||
import { VideoResolution } from '@peertube/peertube-models'
|
||||
import { objectConverter, parseBytes, parseDurationToMs, parseSemVersion } from '@peertube/peertube-server/server/helpers/core-utils.js'
|
||||
|
||||
|
@ -199,3 +199,28 @@ describe('Parse semantic version string', function () {
|
|||
expect(actual.patch).to.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Extract chapters', function () {
|
||||
|
||||
it('Should not extract chapters', function () {
|
||||
expect(parseChapters('my super description\nno?')).to.deep.equal([])
|
||||
expect(parseChapters('m00:00 super description\nno?')).to.deep.equal([])
|
||||
expect(parseChapters('00:00super description\nno?')).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('Should extract chapters', function () {
|
||||
expect(parseChapters('00:00 coucou')).to.deep.equal([ { timecode: 0, title: 'coucou' } ])
|
||||
expect(parseChapters('my super description\n\n00:01:30 chapter 1\n00:01:35 chapter 2')).to.deep.equal([
|
||||
{ timecode: 90, title: 'chapter 1' },
|
||||
{ timecode: 95, title: 'chapter 2' }
|
||||
])
|
||||
expect(parseChapters('hi\n\n00:01:30 chapter 1\n00:01:35 chapter 2\nhi')).to.deep.equal([
|
||||
{ timecode: 90, title: 'chapter 1' },
|
||||
{ timecode: 95, title: 'chapter 2' }
|
||||
])
|
||||
expect(parseChapters('hi\n\n00:01:30 chapter 1\n00:01:35 chapter 2\nhi\n00:01:40 chapter 3')).to.deep.equal([
|
||||
{ timecode: 90, title: 'chapter 1' },
|
||||
{ timecode: 95, title: 'chapter 2' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
@ -3,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'
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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' }),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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 })
|
||||
}
|
|
@ -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 }))
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -465,6 +465,9 @@ const CONSTRAINTS_FIELDS = {
|
|||
},
|
||||
VIDEO_PASSWORD: {
|
||||
LENGTH: { min: 2, max: 100 }
|
||||
},
|
||||
VIDEO_CHAPTERS: {
|
||||
TITLE: { min: 1, max: 100 } // Length
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -60,6 +60,8 @@ export class APVideoCreator extends APVideoAbstractBuilder {
|
|||
return { autoBlacklisted, videoCreated }
|
||||
})
|
||||
|
||||
await this.updateChaptersOutsideTransaction(videoCreated)
|
||||
|
||||
return { autoBlacklisted, videoCreated }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
]
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -10,7 +10,8 @@ export * from './video-blacklist.js'
|
|||
export * from './video-caption.js'
|
||||
export * from './video-change-ownership.js'
|
||||
export * from './video-channel-sync.js'
|
||||
export * from './video-channels.js'
|
||||
export * from './video-channel.js'
|
||||
export * from './video-chapter.js'
|
||||
export * from './video-comment.js'
|
||||
export * from './video-file.js'
|
||||
export * from './video-import.js'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
|
||||
import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils'
|
||||
import { MChannelAccountDefault, MChannelFormattable } from './video-channels.js'
|
||||
import { MChannelAccountDefault, MChannelFormattable } from './video-channel.js'
|
||||
|
||||
type Use<K extends keyof VideoChannelSyncModel, M> = PickWith<VideoChannelSyncModel, K, M>
|
||||
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
|
||||
|
||||
export type MVideoChapter = Omit<VideoChapterModel, 'Video'>
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue