Add video chapters support
This commit is contained in:
parent
7113f32a87
commit
77b70702d2
|
@ -230,6 +230,57 @@
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-container>
|
</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">
|
<ng-container ngbNavItem *ngIf="liveVideo">
|
||||||
<a ngbNavLink i18n>Live settings</a>
|
<a ngbNavLink i18n>Live settings</a>
|
||||||
|
|
||||||
|
@ -312,7 +363,6 @@
|
||||||
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
|
||||||
<ng-container ngbNavItem>
|
<ng-container ngbNavItem>
|
||||||
<a ngbNavLink i18n>Advanced settings</a>
|
<a ngbNavLink i18n>Advanced settings</a>
|
||||||
|
|
||||||
|
|
|
@ -117,6 +117,32 @@ p-calendar {
|
||||||
@include orange-button;
|
@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 {
|
@include on-small-main-col {
|
||||||
.form-columns {
|
.form-columns {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
|
@ -2,10 +2,10 @@ import { forkJoin } from 'rxjs'
|
||||||
import { map } from 'rxjs/operators'
|
import { map } from 'rxjs/operators'
|
||||||
import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model'
|
import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model'
|
||||||
import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
|
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 { HooksService, PluginService, ServerService } from '@app/core'
|
||||||
import { removeElementFromArray } from '@app/helpers'
|
import { removeElementFromArray } from '@app/helpers'
|
||||||
import { BuildFormValidator } from '@app/shared/form-validators'
|
import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators'
|
||||||
import {
|
import {
|
||||||
VIDEO_CATEGORY_VALIDATOR,
|
VIDEO_CATEGORY_VALIDATOR,
|
||||||
VIDEO_CHANNEL_VALIDATOR,
|
VIDEO_CHANNEL_VALIDATOR,
|
||||||
|
@ -20,9 +20,10 @@ import {
|
||||||
VIDEO_SUPPORT_VALIDATOR,
|
VIDEO_SUPPORT_VALIDATOR,
|
||||||
VIDEO_TAGS_ARRAY_VALIDATOR
|
VIDEO_TAGS_ARRAY_VALIDATOR
|
||||||
} from '@app/shared/form-validators/video-validators'
|
} 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 { 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 { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import {
|
import {
|
||||||
HTMLServerConfig,
|
HTMLServerConfig,
|
||||||
|
@ -30,6 +31,7 @@ import {
|
||||||
LiveVideoLatencyMode,
|
LiveVideoLatencyMode,
|
||||||
RegisterClientFormFieldOptions,
|
RegisterClientFormFieldOptions,
|
||||||
RegisterClientVideoFieldOptions,
|
RegisterClientVideoFieldOptions,
|
||||||
|
VideoChapter,
|
||||||
VideoConstant,
|
VideoConstant,
|
||||||
VideoDetails,
|
VideoDetails,
|
||||||
VideoPrivacy,
|
VideoPrivacy,
|
||||||
|
@ -57,7 +59,7 @@ type PluginField = {
|
||||||
})
|
})
|
||||||
export class VideoEditComponent implements OnInit, OnDestroy {
|
export class VideoEditComponent implements OnInit, OnDestroy {
|
||||||
@Input() form: FormGroup
|
@Input() form: FormGroup
|
||||||
@Input() formErrors: { [ id: string ]: string } = {}
|
@Input() formErrors: FormReactiveErrors & { chapters?: { title: string }[] } = {}
|
||||||
@Input() validationMessages: FormReactiveValidationMessages = {}
|
@Input() validationMessages: FormReactiveValidationMessages = {}
|
||||||
|
|
||||||
@Input() videoToUpdate: VideoDetails
|
@Input() videoToUpdate: VideoDetails
|
||||||
|
@ -68,6 +70,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||||
@Input() videoCaptions: VideoCaptionWithPathEdit[] = []
|
@Input() videoCaptions: VideoCaptionWithPathEdit[] = []
|
||||||
@Input() videoSource: VideoSource
|
@Input() videoSource: VideoSource
|
||||||
|
|
||||||
|
@Input() videoChapters: VideoChapter[] = []
|
||||||
|
|
||||||
@Input() hideWaitTranscoding = false
|
@Input() hideWaitTranscoding = false
|
||||||
@Input() updateVideoFileEnabled = false
|
@Input() updateVideoFileEnabled = false
|
||||||
|
|
||||||
|
@ -150,7 +154,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||||
licence: this.serverConfig.defaults.publish.licence,
|
licence: this.serverConfig.defaults.publish.licence,
|
||||||
tags: []
|
tags: []
|
||||||
}
|
}
|
||||||
const obj: { [ id: string ]: BuildFormValidator } = {
|
const obj: BuildFormArgument = {
|
||||||
name: VIDEO_NAME_VALIDATOR,
|
name: VIDEO_NAME_VALIDATOR,
|
||||||
privacy: VIDEO_PRIVACY_VALIDATOR,
|
privacy: VIDEO_PRIVACY_VALIDATOR,
|
||||||
videoPassword: VIDEO_PASSWORD_VALIDATOR,
|
videoPassword: VIDEO_PASSWORD_VALIDATOR,
|
||||||
|
@ -183,12 +187,16 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||||
defaultValues
|
defaultValues
|
||||||
)
|
)
|
||||||
|
|
||||||
this.form.addControl('captions', new FormArray([
|
this.form.addControl('chapters', new FormArray([], VIDEO_CHAPTERS_ARRAY_VALIDATOR.VALIDATORS))
|
||||||
new FormGroup({
|
this.addNewChapterControl()
|
||||||
language: new FormControl(),
|
|
||||||
captionfile: new FormControl()
|
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.trackChannelChange()
|
||||||
this.trackPrivacyChange()
|
this.trackPrivacyChange()
|
||||||
|
@ -426,6 +434,70 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||||
this.form.valueChanges.subscribe(() => this.formValidatorService.updateTreeValidity(this.pluginDataFormGroup))
|
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 () {
|
private trackPrivacyChange () {
|
||||||
// We will update the schedule input and the wait transcoding checkbox validators
|
// We will update the schedule input and the wait transcoding checkbox validators
|
||||||
this.form.controls['privacy']
|
this.form.controls['privacy']
|
||||||
|
@ -469,8 +541,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
||||||
} else {
|
} else {
|
||||||
videoPasswordControl.clearValidators()
|
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 { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
|
||||||
import { scrollToTop } from '@app/helpers'
|
import { scrollToTop } from '@app/helpers'
|
||||||
import { FormReactiveService } from '@app/shared/shared-forms'
|
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 { LiveVideoService } from '@app/shared/shared-video-live'
|
||||||
import { LoadingBarService } from '@ngx-loading-bar/core'
|
import { LoadingBarService } from '@ngx-loading-bar/core'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
|
@ -54,6 +54,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
|
||||||
protected serverService: ServerService,
|
protected serverService: ServerService,
|
||||||
protected videoService: VideoService,
|
protected videoService: VideoService,
|
||||||
protected videoCaptionService: VideoCaptionService,
|
protected videoCaptionService: VideoCaptionService,
|
||||||
|
protected videoChapterService: VideoChapterService,
|
||||||
private liveVideoService: LiveVideoService,
|
private liveVideoService: LiveVideoService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private hooks: HooksService
|
private hooks: HooksService
|
||||||
|
@ -137,6 +138,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
|
||||||
video.uuid = this.videoUUID
|
video.uuid = this.videoUUID
|
||||||
video.shortUUID = this.videoShortUUID
|
video.shortUUID = this.videoShortUUID
|
||||||
|
|
||||||
|
this.chaptersEdit.patch(this.form.value)
|
||||||
|
|
||||||
const saveReplay = this.form.value.saveReplay
|
const saveReplay = this.form.value.saveReplay
|
||||||
const replaySettings = saveReplay
|
const replaySettings = saveReplay
|
||||||
? { privacy: this.form.value.replayPrivacy }
|
? { privacy: this.form.value.replayPrivacy }
|
||||||
|
@ -151,7 +154,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
|
||||||
|
|
||||||
// Update the video
|
// Update the video
|
||||||
forkJoin([
|
forkJoin([
|
||||||
this.updateVideoAndCaptions(video),
|
this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions }),
|
||||||
|
|
||||||
this.liveVideoService.updateLive(this.videoId, liveVideoUpdate)
|
this.liveVideoService.updateLive(this.videoId, liveVideoUpdate)
|
||||||
]).subscribe({
|
]).subscribe({
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { Router } from '@angular/router'
|
||||||
import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
|
import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
|
||||||
import { scrollToTop } from '@app/helpers'
|
import { scrollToTop } from '@app/helpers'
|
||||||
import { FormReactiveService } from '@app/shared/shared-forms'
|
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 { LoadingBarService } from '@ngx-loading-bar/core'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { PeerTubeProblemDocument, ServerErrorCode, VideoUpdate } from '@peertube/peertube-models'
|
import { PeerTubeProblemDocument, ServerErrorCode, VideoUpdate } from '@peertube/peertube-models'
|
||||||
|
@ -42,6 +42,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af
|
||||||
protected serverService: ServerService,
|
protected serverService: ServerService,
|
||||||
protected videoService: VideoService,
|
protected videoService: VideoService,
|
||||||
protected videoCaptionService: VideoCaptionService,
|
protected videoCaptionService: VideoCaptionService,
|
||||||
|
protected videoChapterService: VideoChapterService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private videoImportService: VideoImportService,
|
private videoImportService: VideoImportService,
|
||||||
private hooks: HooksService
|
private hooks: HooksService
|
||||||
|
@ -124,24 +125,25 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af
|
||||||
if (!await this.isFormValid()) return
|
if (!await this.isFormValid()) return
|
||||||
|
|
||||||
this.video.patch(this.form.value)
|
this.video.patch(this.form.value)
|
||||||
|
this.chaptersEdit.patch(this.form.value)
|
||||||
|
|
||||||
this.isUpdatingVideo = true
|
this.isUpdatingVideo = true
|
||||||
|
|
||||||
// Update the video
|
// Update the video
|
||||||
this.updateVideoAndCaptions(this.video)
|
this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit })
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.isUpdatingVideo = false
|
this.isUpdatingVideo = false
|
||||||
this.notifier.success($localize`Video to import updated.`)
|
this.notifier.success($localize`Video to import updated.`)
|
||||||
|
|
||||||
this.router.navigate([ '/my-library', 'video-imports' ])
|
this.router.navigate([ '/my-library', 'video-imports' ])
|
||||||
},
|
},
|
||||||
|
|
||||||
error: err => {
|
error: err => {
|
||||||
this.error = err.message
|
this.error = err.message
|
||||||
scrollToTop()
|
scrollToTop()
|
||||||
logger.error(err)
|
logger.error(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,6 +56,7 @@
|
||||||
<!-- Hidden because we want to load the component -->
|
<!-- Hidden because we want to load the component -->
|
||||||
<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
|
<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
|
||||||
<my-video-edit
|
<my-video-edit
|
||||||
|
#videoEdit
|
||||||
[form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [forbidScheduledPublication]="true"
|
[form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [forbidScheduledPublication]="true"
|
||||||
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
|
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
|
||||||
type="import-url"
|
type="import-url"
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
import { forkJoin } from 'rxjs'
|
import { forkJoin } from 'rxjs'
|
||||||
import { map, switchMap } from 'rxjs/operators'
|
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 { Router } from '@angular/router'
|
||||||
import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
|
import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
|
||||||
import { scrollToTop } from '@app/helpers'
|
import { scrollToTop } from '@app/helpers'
|
||||||
import { FormReactiveService } from '@app/shared/shared-forms'
|
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 { LoadingBarService } from '@ngx-loading-bar/core'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { VideoUpdate } from '@peertube/peertube-models'
|
import { VideoUpdate } from '@peertube/peertube-models'
|
||||||
import { hydrateFormFromVideo } from '../shared/video-edit-utils'
|
import { hydrateFormFromVideo } from '../shared/video-edit-utils'
|
||||||
import { VideoSend } from './video-send'
|
import { VideoSend } from './video-send'
|
||||||
|
import { VideoEditComponent } from '../shared/video-edit.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-video-import-url',
|
selector: 'my-video-import-url',
|
||||||
|
@ -21,6 +22,8 @@ import { VideoSend } from './video-send'
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterViewInit, CanComponentDeactivate {
|
export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterViewInit, CanComponentDeactivate {
|
||||||
|
@ViewChild('videoEdit', { static: false }) videoEditComponent: VideoEditComponent
|
||||||
|
|
||||||
@Output() firstStepDone = new EventEmitter<string>()
|
@Output() firstStepDone = new EventEmitter<string>()
|
||||||
@Output() firstStepError = new EventEmitter<void>()
|
@Output() firstStepError = new EventEmitter<void>()
|
||||||
|
|
||||||
|
@ -41,6 +44,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
|
||||||
protected serverService: ServerService,
|
protected serverService: ServerService,
|
||||||
protected videoService: VideoService,
|
protected videoService: VideoService,
|
||||||
protected videoCaptionService: VideoCaptionService,
|
protected videoCaptionService: VideoCaptionService,
|
||||||
|
protected videoChapterService: VideoChapterService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private videoImportService: VideoImportService,
|
private videoImportService: VideoImportService,
|
||||||
private hooks: HooksService
|
private hooks: HooksService
|
||||||
|
@ -85,12 +89,13 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
|
||||||
switchMap(previous => {
|
switchMap(previous => {
|
||||||
return forkJoin([
|
return forkJoin([
|
||||||
this.videoCaptionService.listCaptions(previous.video.uuid),
|
this.videoCaptionService.listCaptions(previous.video.uuid),
|
||||||
|
this.videoChapterService.getChapters({ videoId: previous.video.uuid }),
|
||||||
this.videoService.getVideo({ 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({
|
.subscribe({
|
||||||
next: ({ video, videoCaptions }) => {
|
next: ({ video, videoCaptions, chapters }) => {
|
||||||
this.loadingBar.useRef().complete()
|
this.loadingBar.useRef().complete()
|
||||||
this.firstStepDone.emit(video.name)
|
this.firstStepDone.emit(video.name)
|
||||||
this.isImportingVideo = false
|
this.isImportingVideo = false
|
||||||
|
@ -99,9 +104,12 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
|
||||||
this.video = new VideoEdit(video)
|
this.video = new VideoEdit(video)
|
||||||
this.video.patch({ privacy: this.firstStepPrivacyId })
|
this.video.patch({ privacy: this.firstStepPrivacyId })
|
||||||
|
|
||||||
|
this.chaptersEdit.loadFromAPI(chapters)
|
||||||
|
|
||||||
this.videoCaptions = videoCaptions
|
this.videoCaptions = videoCaptions
|
||||||
|
|
||||||
hydrateFormFromVideo(this.form, this.video, true)
|
hydrateFormFromVideo(this.form, this.video, true)
|
||||||
|
setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit))
|
||||||
},
|
},
|
||||||
|
|
||||||
error: err => {
|
error: err => {
|
||||||
|
@ -117,11 +125,12 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
|
||||||
if (!await this.isFormValid()) return
|
if (!await this.isFormValid()) return
|
||||||
|
|
||||||
this.video.patch(this.form.value)
|
this.video.patch(this.form.value)
|
||||||
|
this.chaptersEdit.patch(this.form.value)
|
||||||
|
|
||||||
this.isUpdatingVideo = true
|
this.isUpdatingVideo = true
|
||||||
|
|
||||||
// Update the video
|
// Update the video
|
||||||
this.updateVideoAndCaptions(this.video)
|
this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit })
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.isUpdatingVideo = false
|
this.isUpdatingVideo = false
|
||||||
|
|
|
@ -4,9 +4,17 @@ import { Directive, EventEmitter, OnInit } from '@angular/core'
|
||||||
import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core'
|
import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core'
|
||||||
import { listUserChannelsForSelect } from '@app/helpers'
|
import { listUserChannelsForSelect } from '@app/helpers'
|
||||||
import { FormReactive } from '@app/shared/shared-forms'
|
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 { LoadingBarService } from '@ngx-loading-bar/core'
|
||||||
import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models'
|
import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models'
|
||||||
|
import { of } from 'rxjs'
|
||||||
|
|
||||||
@Directive()
|
@Directive()
|
||||||
// eslint-disable-next-line @angular-eslint/directive-class-suffix
|
// eslint-disable-next-line @angular-eslint/directive-class-suffix
|
||||||
|
@ -14,6 +22,7 @@ export abstract class VideoSend extends FormReactive implements OnInit {
|
||||||
userVideoChannels: SelectChannelItem[] = []
|
userVideoChannels: SelectChannelItem[] = []
|
||||||
videoPrivacies: VideoConstant<VideoPrivacyType>[] = []
|
videoPrivacies: VideoConstant<VideoPrivacyType>[] = []
|
||||||
videoCaptions: VideoCaptionEdit[] = []
|
videoCaptions: VideoCaptionEdit[] = []
|
||||||
|
chaptersEdit = new VideoChaptersEdit()
|
||||||
|
|
||||||
firstStepPrivacyId: VideoPrivacyType
|
firstStepPrivacyId: VideoPrivacyType
|
||||||
firstStepChannelId: number
|
firstStepChannelId: number
|
||||||
|
@ -28,6 +37,7 @@ export abstract class VideoSend extends FormReactive implements OnInit {
|
||||||
protected serverService: ServerService
|
protected serverService: ServerService
|
||||||
protected videoService: VideoService
|
protected videoService: VideoService
|
||||||
protected videoCaptionService: VideoCaptionService
|
protected videoCaptionService: VideoCaptionService
|
||||||
|
protected videoChapterService: VideoChapterService
|
||||||
|
|
||||||
protected serverConfig: HTMLServerConfig
|
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()
|
this.loadingBar.useRef().start()
|
||||||
|
|
||||||
return this.videoService.updateVideo(video)
|
return this.videoService.updateVideo(video)
|
||||||
.pipe(
|
.pipe(
|
||||||
// Then update captions
|
switchMap(() => this.videoCaptionService.updateCaptions(video.uuid, captions)),
|
||||||
switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions)),
|
switchMap(() => {
|
||||||
|
return chapters
|
||||||
|
? this.videoChapterService.updateChapters(video.uuid, chapters)
|
||||||
|
: of(true)
|
||||||
|
}),
|
||||||
tap(() => this.loadingBar.useRef().complete()),
|
tap(() => this.loadingBar.useRef().complete()),
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
this.loadingBar.useRef().complete()
|
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 { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
|
||||||
import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
|
import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
|
||||||
import { FormReactiveService } from '@app/shared/shared-forms'
|
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 { LoadingBarService } from '@ngx-loading-bar/core'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models'
|
import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models'
|
||||||
|
@ -63,6 +63,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
|
||||||
protected serverService: ServerService,
|
protected serverService: ServerService,
|
||||||
protected videoService: VideoService,
|
protected videoService: VideoService,
|
||||||
protected videoCaptionService: VideoCaptionService,
|
protected videoCaptionService: VideoCaptionService,
|
||||||
|
protected videoChapterService: VideoChapterService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private hooks: HooksService,
|
private hooks: HooksService,
|
||||||
|
@ -241,9 +242,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
|
||||||
video.uuid = this.videoUploadedIds.uuid
|
video.uuid = this.videoUploadedIds.uuid
|
||||||
video.shortUUID = this.videoUploadedIds.shortUUID
|
video.shortUUID = this.videoUploadedIds.shortUUID
|
||||||
|
|
||||||
|
this.chaptersEdit.patch(this.form.value)
|
||||||
|
|
||||||
this.isUpdatingVideo = true
|
this.isUpdatingVideo = true
|
||||||
|
|
||||||
this.updateVideoAndCaptions(video)
|
this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions, chapters: this.chaptersEdit })
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.isUpdatingVideo = false
|
this.isUpdatingVideo = false
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
<form novalidate [formGroup]="form">
|
<form novalidate [formGroup]="form">
|
||||||
|
|
||||||
<my-video-edit
|
<my-video-edit
|
||||||
|
#videoEdit
|
||||||
[form]="form" [formErrors]="formErrors" [forbidScheduledPublication]="forbidScheduledPublication"
|
[form]="form" [formErrors]="formErrors" [forbidScheduledPublication]="forbidScheduledPublication"
|
||||||
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
|
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
|
||||||
[videoCaptions]="videoCaptions" [hideWaitTranscoding]="isWaitTranscodingHidden()"
|
[videoCaptions]="videoCaptions" [hideWaitTranscoding]="isWaitTranscodingHidden()"
|
||||||
|
|
|
@ -4,18 +4,28 @@ import { of, Subject, Subscription } from 'rxjs'
|
||||||
import { catchError, map, switchMap } from 'rxjs/operators'
|
import { catchError, map, switchMap } from 'rxjs/operators'
|
||||||
import { SelectChannelItem } from 'src/types/select-options-item.model'
|
import { SelectChannelItem } from 'src/types/select-options-item.model'
|
||||||
import { HttpErrorResponse } from '@angular/common/http'
|
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 { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core'
|
import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core'
|
||||||
import { genericUploadErrorHandler } from '@app/helpers'
|
import { genericUploadErrorHandler } from '@app/helpers'
|
||||||
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
|
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 { LiveVideoService } from '@app/shared/shared-video-live'
|
||||||
import { LoadingBarService } from '@ngx-loading-bar/core'
|
import { LoadingBarService } from '@ngx-loading-bar/core'
|
||||||
import { pick, simpleObjectsDeepEqual } from '@peertube/peertube-core-utils'
|
import { pick, simpleObjectsDeepEqual } from '@peertube/peertube-core-utils'
|
||||||
import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoSource, VideoState } from '@peertube/peertube-models'
|
import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoSource, VideoState } from '@peertube/peertube-models'
|
||||||
import { hydrateFormFromVideo } from './shared/video-edit-utils'
|
import { hydrateFormFromVideo } from './shared/video-edit-utils'
|
||||||
import { VideoUploadService } from './shared/video-upload.service'
|
import { VideoUploadService } from './shared/video-upload.service'
|
||||||
|
import { VideoEditComponent } from './shared/video-edit.component'
|
||||||
|
|
||||||
const debugLogger = debug('peertube:video-update')
|
const debugLogger = debug('peertube:video-update')
|
||||||
|
|
||||||
|
@ -25,6 +35,8 @@ const debugLogger = debug('peertube:video-update')
|
||||||
templateUrl: './video-update.component.html'
|
templateUrl: './video-update.component.html'
|
||||||
})
|
})
|
||||||
export class VideoUpdateComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate {
|
export class VideoUpdateComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate {
|
||||||
|
@ViewChild('videoEdit', { static: false }) videoEditComponent: VideoEditComponent
|
||||||
|
|
||||||
videoEdit: VideoEdit
|
videoEdit: VideoEdit
|
||||||
videoDetails: VideoDetails
|
videoDetails: VideoDetails
|
||||||
videoSource: VideoSource
|
videoSource: VideoSource
|
||||||
|
@ -50,6 +62,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
|
||||||
private uploadServiceSubscription: Subscription
|
private uploadServiceSubscription: Subscription
|
||||||
private updateSubcription: Subscription
|
private updateSubcription: Subscription
|
||||||
|
|
||||||
|
private chaptersEdit = new VideoChaptersEdit()
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
protected formReactiveService: FormReactiveService,
|
protected formReactiveService: FormReactiveService,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
|
@ -58,6 +72,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
|
||||||
private videoService: VideoService,
|
private videoService: VideoService,
|
||||||
private loadingBar: LoadingBarService,
|
private loadingBar: LoadingBarService,
|
||||||
private videoCaptionService: VideoCaptionService,
|
private videoCaptionService: VideoCaptionService,
|
||||||
|
private videoChapterService: VideoChapterService,
|
||||||
private server: ServerService,
|
private server: ServerService,
|
||||||
private liveVideoService: LiveVideoService,
|
private liveVideoService: LiveVideoService,
|
||||||
private videoUploadService: VideoUploadService,
|
private videoUploadService: VideoUploadService,
|
||||||
|
@ -84,10 +99,11 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
|
||||||
.subscribe(state => this.onUploadVideoOngoing(state))
|
.subscribe(state => this.onUploadVideoOngoing(state))
|
||||||
|
|
||||||
const { videoData } = this.route.snapshot.data
|
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.videoDetails = video
|
||||||
this.videoEdit = new VideoEdit(this.videoDetails, videoPassword)
|
this.videoEdit = new VideoEdit(this.videoDetails, videoPassword)
|
||||||
|
this.chaptersEdit.loadFromAPI(videoChapters)
|
||||||
|
|
||||||
this.userVideoChannels = videoChannels
|
this.userVideoChannels = videoChannels
|
||||||
this.videoCaptions = videoCaptions
|
this.videoCaptions = videoCaptions
|
||||||
|
@ -106,6 +122,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
|
||||||
onFormBuilt () {
|
onFormBuilt () {
|
||||||
hydrateFormFromVideo(this.form, this.videoEdit, true)
|
hydrateFormFromVideo(this.form, this.videoEdit, true)
|
||||||
|
|
||||||
|
setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit))
|
||||||
|
|
||||||
if (this.liveVideo) {
|
if (this.liveVideo) {
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
saveReplay: this.liveVideo.saveReplay,
|
saveReplay: this.liveVideo.saveReplay,
|
||||||
|
@ -172,6 +190,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
|
||||||
if (!await this.checkAndConfirmVideoFileReplacement()) return
|
if (!await this.checkAndConfirmVideoFileReplacement()) return
|
||||||
|
|
||||||
this.videoEdit.patch(this.form.value)
|
this.videoEdit.patch(this.form.value)
|
||||||
|
this.chaptersEdit.patch(this.form.value)
|
||||||
|
|
||||||
this.abortUpdateIfNeeded()
|
this.abortUpdateIfNeeded()
|
||||||
|
|
||||||
|
@ -180,10 +199,12 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest
|
||||||
|
|
||||||
this.updateSubcription = this.videoReplacementUploadedSubject.pipe(
|
this.updateSubcription = this.videoReplacementUploadedSubject.pipe(
|
||||||
switchMap(() => this.videoService.updateVideo(this.videoEdit)),
|
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
|
return this.videoChapterService.updateChapters(this.videoEdit.uuid, this.chaptersEdit)
|
||||||
switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)),
|
}),
|
||||||
|
|
||||||
switchMap(() => {
|
switchMap(() => {
|
||||||
if (!this.liveVideo) return of(undefined)
|
if (!this.liveVideo) return of(undefined)
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { Injectable } from '@angular/core'
|
||||||
import { ActivatedRouteSnapshot } from '@angular/router'
|
import { ActivatedRouteSnapshot } from '@angular/router'
|
||||||
import { AuthService } from '@app/core'
|
import { AuthService } from '@app/core'
|
||||||
import { listUserChannelsForSelect } from '@app/helpers'
|
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 { LiveVideoService } from '@app/shared/shared-video-live'
|
||||||
import { VideoPrivacy } from '@peertube/peertube-models'
|
import { VideoPrivacy } from '@peertube/peertube-models'
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ export class VideoUpdateResolver {
|
||||||
private liveVideoService: LiveVideoService,
|
private liveVideoService: LiveVideoService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private videoCaptionService: VideoCaptionService,
|
private videoCaptionService: VideoCaptionService,
|
||||||
|
private videoChapterService: VideoChapterService,
|
||||||
private videoPasswordService: VideoPasswordService
|
private videoPasswordService: VideoPasswordService
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
@ -25,8 +26,8 @@ export class VideoUpdateResolver {
|
||||||
return this.videoService.getVideo({ videoId: uuid })
|
return this.videoService.getVideo({ videoId: uuid })
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(video => forkJoin(this.buildVideoObservables(video))),
|
switchMap(video => forkJoin(this.buildVideoObservables(video))),
|
||||||
map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) =>
|
map(([ video, videoSource, videoChannels, videoCaptions, videoChapters, liveVideo, videoPassword ]) =>
|
||||||
({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword }))
|
({ video, videoChannels, videoCaptions, videoChapters, videoSource, liveVideo, videoPassword }))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,6 +47,12 @@ export class VideoUpdateResolver {
|
||||||
map(result => result.data)
|
map(result => result.data)
|
||||||
),
|
),
|
||||||
|
|
||||||
|
this.videoChapterService
|
||||||
|
.getChapters({ videoId: video.uuid })
|
||||||
|
.pipe(
|
||||||
|
map(({ chapters }) => chapters)
|
||||||
|
),
|
||||||
|
|
||||||
video.isLive
|
video.isLive
|
||||||
? this.liveVideoService.getVideoLive(video.id)
|
? this.liveVideoService.getVideoLive(video.id)
|
||||||
: of(undefined),
|
: of(undefined),
|
||||||
|
|
|
@ -18,7 +18,7 @@ import {
|
||||||
} from '@app/core'
|
} from '@app/core'
|
||||||
import { HooksService } from '@app/core/plugins/hooks.service'
|
import { HooksService } from '@app/core/plugins/hooks.service'
|
||||||
import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers'
|
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 { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
|
||||||
import { LiveVideoService } from '@app/shared/shared-video-live'
|
import { LiveVideoService } from '@app/shared/shared-video-live'
|
||||||
import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
|
import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
|
||||||
|
@ -31,6 +31,7 @@ import {
|
||||||
ServerErrorCode,
|
ServerErrorCode,
|
||||||
Storyboard,
|
Storyboard,
|
||||||
VideoCaption,
|
VideoCaption,
|
||||||
|
VideoChapter,
|
||||||
VideoPrivacy,
|
VideoPrivacy,
|
||||||
VideoState,
|
VideoState,
|
||||||
VideoStateType
|
VideoStateType
|
||||||
|
@ -83,6 +84,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
video: VideoDetails = null
|
video: VideoDetails = null
|
||||||
videoCaptions: VideoCaption[] = []
|
videoCaptions: VideoCaption[] = []
|
||||||
|
videoChapters: VideoChapter[] = []
|
||||||
liveVideo: LiveVideo
|
liveVideo: LiveVideo
|
||||||
videoPassword: string
|
videoPassword: string
|
||||||
storyboards: Storyboard[] = []
|
storyboards: Storyboard[] = []
|
||||||
|
@ -125,6 +127,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
private notifier: Notifier,
|
private notifier: Notifier,
|
||||||
private zone: NgZone,
|
private zone: NgZone,
|
||||||
private videoCaptionService: VideoCaptionService,
|
private videoCaptionService: VideoCaptionService,
|
||||||
|
private videoChapterService: VideoChapterService,
|
||||||
private hotkeysService: HotkeysService,
|
private hotkeysService: HotkeysService,
|
||||||
private hooks: HooksService,
|
private hooks: HooksService,
|
||||||
private pluginService: PluginService,
|
private pluginService: PluginService,
|
||||||
|
@ -306,14 +309,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
forkJoin([
|
forkJoin([
|
||||||
videoAndLiveObs,
|
videoAndLiveObs,
|
||||||
this.videoCaptionService.listCaptions(videoId, videoPassword),
|
this.videoCaptionService.listCaptions(videoId, videoPassword),
|
||||||
|
this.videoChapterService.getChapters({ videoId, videoPassword }),
|
||||||
this.videoService.getStoryboards(videoId, videoPassword),
|
this.videoService.getStoryboards(videoId, videoPassword),
|
||||||
this.userService.getAnonymousOrLoggedUser()
|
this.userService.getAnonymousOrLoggedUser()
|
||||||
]).subscribe({
|
]).subscribe({
|
||||||
next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => {
|
next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, loggedInOrAnonymousUser ]) => {
|
||||||
this.onVideoFetched({
|
this.onVideoFetched({
|
||||||
video,
|
video,
|
||||||
live,
|
live,
|
||||||
videoCaptions: captionsResult.data,
|
videoCaptions: captionsResult.data,
|
||||||
|
videoChapters: chaptersResult.chapters,
|
||||||
storyboards,
|
storyboards,
|
||||||
videoFileToken,
|
videoFileToken,
|
||||||
videoPassword,
|
videoPassword,
|
||||||
|
@ -411,6 +416,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
video: VideoDetails
|
video: VideoDetails
|
||||||
live: LiveVideo
|
live: LiveVideo
|
||||||
videoCaptions: VideoCaption[]
|
videoCaptions: VideoCaption[]
|
||||||
|
videoChapters: VideoChapter[]
|
||||||
storyboards: Storyboard[]
|
storyboards: Storyboard[]
|
||||||
videoFileToken: string
|
videoFileToken: string
|
||||||
videoPassword: string
|
videoPassword: string
|
||||||
|
@ -422,6 +428,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
video,
|
video,
|
||||||
live,
|
live,
|
||||||
videoCaptions,
|
videoCaptions,
|
||||||
|
videoChapters,
|
||||||
storyboards,
|
storyboards,
|
||||||
videoFileToken,
|
videoFileToken,
|
||||||
videoPassword,
|
videoPassword,
|
||||||
|
@ -433,6 +440,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
this.video = video
|
this.video = video
|
||||||
this.videoCaptions = videoCaptions
|
this.videoCaptions = videoCaptions
|
||||||
|
this.videoChapters = videoChapters
|
||||||
this.liveVideo = live
|
this.liveVideo = live
|
||||||
this.videoFileToken = videoFileToken
|
this.videoFileToken = videoFileToken
|
||||||
this.videoPassword = videoPassword
|
this.videoPassword = videoPassword
|
||||||
|
@ -480,6 +488,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
const params = {
|
const params = {
|
||||||
video: this.video,
|
video: this.video,
|
||||||
videoCaptions: this.videoCaptions,
|
videoCaptions: this.videoCaptions,
|
||||||
|
videoChapters: this.videoChapters,
|
||||||
storyboards: this.storyboards,
|
storyboards: this.storyboards,
|
||||||
liveVideo: this.liveVideo,
|
liveVideo: this.liveVideo,
|
||||||
videoFileToken: this.videoFileToken,
|
videoFileToken: this.videoFileToken,
|
||||||
|
@ -636,6 +645,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
video: VideoDetails
|
video: VideoDetails
|
||||||
liveVideo: LiveVideo
|
liveVideo: LiveVideo
|
||||||
videoCaptions: VideoCaption[]
|
videoCaptions: VideoCaption[]
|
||||||
|
videoChapters: VideoChapter[]
|
||||||
storyboards: Storyboard[]
|
storyboards: Storyboard[]
|
||||||
|
|
||||||
videoFileToken: string
|
videoFileToken: string
|
||||||
|
@ -651,6 +661,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
video,
|
video,
|
||||||
liveVideo,
|
liveVideo,
|
||||||
videoCaptions,
|
videoCaptions,
|
||||||
|
videoChapters,
|
||||||
storyboards,
|
storyboards,
|
||||||
videoFileToken,
|
videoFileToken,
|
||||||
videoPassword,
|
videoPassword,
|
||||||
|
@ -750,6 +761,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
videoPassword: () => videoPassword,
|
videoPassword: () => videoPassword,
|
||||||
|
|
||||||
videoCaptions: playerCaptions,
|
videoCaptions: playerCaptions,
|
||||||
|
videoChapters,
|
||||||
storyboard,
|
storyboard,
|
||||||
|
|
||||||
videoShortUUID: video.shortUUID,
|
videoShortUUID: video.shortUUID,
|
||||||
|
|
|
@ -7,17 +7,6 @@ function removeElementFromArray <T> (arr: T[], elem: T) {
|
||||||
if (index !== -1) arr.splice(index, 1)
|
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) {
|
function splitIntoArray (value: any) {
|
||||||
if (!value) return undefined
|
if (!value) return undefined
|
||||||
if (Array.isArray(value)) return value
|
if (Array.isArray(value)) return value
|
||||||
|
@ -41,7 +30,6 @@ function toBoolean (value: any) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
sortBy,
|
|
||||||
immutableAssign,
|
immutableAssign,
|
||||||
removeElementFromArray,
|
removeElementFromArray,
|
||||||
splitIntoArray,
|
splitIntoArray,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
|
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 { 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({
|
@Component({
|
||||||
selector: 'my-language-chooser',
|
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 = {
|
export const VIDEO_TAGS_ARRAY_VALIDATOR: BuildFormValidator = {
|
||||||
VALIDATORS: [ Validators.maxLength(5), arrayTagLengthValidator() ],
|
VALIDATORS: [ Validators.maxLength(5), arrayTagLengthValidator() ],
|
||||||
MESSAGES: {
|
MESSAGES: {
|
||||||
|
|
|
@ -4,9 +4,9 @@ import { wait } from '@root-helpers/utils'
|
||||||
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
|
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
|
||||||
import { FormValidatorService } from './form-validator.service'
|
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 = {
|
export type FormReactiveValidationMessages = {
|
||||||
[ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
|
[ id: string | number ]: { [ name: string ]: string } | FormReactiveValidationMessages | FormReactiveValidationMessages[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -86,7 +86,7 @@ export class FormReactiveService {
|
||||||
|
|
||||||
if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue
|
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)) {
|
for (const key of Object.keys(control.errors)) {
|
||||||
const formErrorValue = control.errors[key]
|
const formErrorValue = control.errors[key]
|
||||||
|
|
||||||
|
|
|
@ -45,20 +45,20 @@ export class FormValidatorService {
|
||||||
form: FormGroup,
|
form: FormGroup,
|
||||||
formErrors: FormReactiveErrors,
|
formErrors: FormReactiveErrors,
|
||||||
validationMessages: FormReactiveValidationMessages,
|
validationMessages: FormReactiveValidationMessages,
|
||||||
obj: BuildFormArgument,
|
formToBuild: BuildFormArgument,
|
||||||
defaultValues: BuildFormDefaultValues = {}
|
defaultValues: BuildFormDefaultValues = {}
|
||||||
) {
|
) {
|
||||||
for (const name of objectKeysTyped(obj)) {
|
for (const name of objectKeysTyped(formToBuild)) {
|
||||||
formErrors[name] = ''
|
formErrors[name] = ''
|
||||||
|
|
||||||
const field = obj[name]
|
const field = formToBuild[name]
|
||||||
if (this.isRecursiveField(field)) {
|
if (this.isRecursiveField(field)) {
|
||||||
this.updateFormGroup(
|
this.updateFormGroup(
|
||||||
// FIXME: typings
|
// FIXME: typings
|
||||||
(form as any)[name],
|
(form as any)[name],
|
||||||
formErrors[name] as FormReactiveErrors,
|
formErrors[name] as FormReactiveErrors,
|
||||||
validationMessages[name] as FormReactiveValidationMessages,
|
validationMessages[name] as FormReactiveValidationMessages,
|
||||||
obj[name] as BuildFormArgument,
|
formToBuild[name] as BuildFormArgument,
|
||||||
defaultValues[name] as BuildFormDefaultValues
|
defaultValues[name] as BuildFormDefaultValues
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
@ -66,7 +66,7 @@ export class FormValidatorService {
|
||||||
|
|
||||||
if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
|
if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
|
||||||
|
|
||||||
const defaultValue = defaultValues[name] || ''
|
const defaultValue = defaultValues[name] ?? ''
|
||||||
|
|
||||||
form.addControl(
|
form.addControl(
|
||||||
name + '',
|
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 {
|
updateTreeValidity (group: FormGroup | FormArray): void {
|
||||||
for (const key of Object.keys(group.controls)) {
|
for (const key of Object.keys(group.controls)) {
|
||||||
// FIXME: typings
|
// FIXME: typings
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core'
|
import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core'
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||||
import { Notifier } from '@app/core'
|
import { FormReactiveErrors } from './form-reactive.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-input-text',
|
selector: 'my-input-text',
|
||||||
|
@ -26,9 +26,7 @@ export class InputTextComponent implements ControlValueAccessor {
|
||||||
@Input() withCopy = false
|
@Input() withCopy = false
|
||||||
@Input() readonly = false
|
@Input() readonly = false
|
||||||
@Input() show = false
|
@Input() show = false
|
||||||
@Input() formError: string
|
@Input() formError: string | FormReactiveErrors | FormReactiveErrors[]
|
||||||
|
|
||||||
constructor (private notifier: Notifier) { }
|
|
||||||
|
|
||||||
get inputType () {
|
get inputType () {
|
||||||
return this.show
|
return this.show
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-container>
|
</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]="maximizeInText" iconName="fullscreen"></my-global-icon>
|
||||||
|
|
||||||
<my-global-icon *ngIf="isMaximized" [ngbTooltip]="maximizeOutText" iconName="exit-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 { SafeHtml } from '@angular/platform-browser'
|
||||||
import { MarkdownService, ScreenService } from '@app/core'
|
import { MarkdownService, ScreenService } from '@app/core'
|
||||||
import { Video } from '@peertube/peertube-models'
|
import { Video } from '@peertube/peertube-models'
|
||||||
|
import { FormReactiveErrors } from './form-reactive.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-markdown-textarea',
|
selector: 'my-markdown-textarea',
|
||||||
|
@ -23,7 +24,7 @@ import { Video } from '@peertube/peertube-models'
|
||||||
export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
|
export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
|
||||||
@Input() content = ''
|
@Input() content = ''
|
||||||
|
|
||||||
@Input() formError: string
|
@Input() formError: string | FormReactiveErrors | FormReactiveErrors[]
|
||||||
|
|
||||||
@Input() truncateTo3Lines: boolean
|
@Input() truncateTo3Lines: boolean
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
p-inputmask {
|
p-inputmask {
|
||||||
::ng-deep input {
|
::ng-deep input {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
&:focus-within,
|
&:focus-within,
|
||||||
&:focus {
|
&: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>
|
<ng-container *ngTemplateOutlet="content"></ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
|
@ -49,6 +49,7 @@ import { UserHistoryService, UserNotificationsComponent, UserNotificationService
|
||||||
import {
|
import {
|
||||||
EmbedComponent,
|
EmbedComponent,
|
||||||
RedundancyService,
|
RedundancyService,
|
||||||
|
VideoChapterService,
|
||||||
VideoFileTokenService,
|
VideoFileTokenService,
|
||||||
VideoImportService,
|
VideoImportService,
|
||||||
VideoOwnershipService,
|
VideoOwnershipService,
|
||||||
|
@ -215,6 +216,8 @@ import { VideoChannelService } from './video-channel'
|
||||||
|
|
||||||
VideoPasswordService,
|
VideoPasswordService,
|
||||||
|
|
||||||
|
VideoChapterService,
|
||||||
|
|
||||||
CustomPageService,
|
CustomPageService,
|
||||||
|
|
||||||
ActorRedirectGuard
|
ActorRedirectGuard
|
||||||
|
|
|
@ -3,9 +3,9 @@ import { catchError, map, switchMap } from 'rxjs/operators'
|
||||||
import { HttpClient } from '@angular/common/http'
|
import { HttpClient } from '@angular/common/http'
|
||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { RestExtractor, ServerService } from '@app/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 { 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 { ResultList, VideoCaption } from '@peertube/peertube-models'
|
||||||
import { environment } from '../../../../environments/environment'
|
import { environment } from '../../../../environments/environment'
|
||||||
import { VideoCaptionEdit } from './video-caption-edit.model'
|
import { VideoCaptionEdit } from './video-caption-edit.model'
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
export * from './embed.component'
|
export * from './embed.component'
|
||||||
export * from './redundancy.service'
|
export * from './redundancy.service'
|
||||||
|
export * from './video-chapter.service'
|
||||||
|
export * from './video-chapters-edit.model'
|
||||||
export * from './video-details.model'
|
export * from './video-details.model'
|
||||||
export * from './video-edit.model'
|
export * from './video-edit.model'
|
||||||
export * from './video-file-token.service'
|
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/peertube/peertube-plugin'
|
||||||
import './shared/resolutions/peertube-resolutions-plugin'
|
import './shared/resolutions/peertube-resolutions-plugin'
|
||||||
import './shared/control-bar/storyboard-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/next-previous-video-button'
|
||||||
import './shared/control-bar/p2p-info-button'
|
import './shared/control-bar/p2p-info-button'
|
||||||
import './shared/control-bar/peertube-link-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('upnext')) this.player.upnext().dispose()
|
||||||
if (this.player.usingPlugin('stats')) this.player.stats().dispose()
|
if (this.player.usingPlugin('stats')) this.player.stats().dispose()
|
||||||
if (this.player.usingPlugin('storyboard')) this.player.storyboard().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()
|
if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose()
|
||||||
|
|
||||||
|
@ -273,6 +276,10 @@ export class PeerTubePlayer {
|
||||||
this.player.storyboard(this.currentLoadOptions.storyboard)
|
this.player.storyboard(this.currentLoadOptions.storyboard)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.currentLoadOptions.videoChapters) {
|
||||||
|
this.player.chapters({ chapters: this.currentLoadOptions.videoChapters })
|
||||||
|
}
|
||||||
|
|
||||||
if (this.currentLoadOptions.dock) {
|
if (this.currentLoadOptions.dock) {
|
||||||
this.player.peertubeDock(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 './next-previous-video-button'
|
||||||
export * from './p2p-info-button'
|
export * from './p2p-info-button'
|
||||||
export * from './peertube-link-button'
|
export * from './peertube-link-button'
|
||||||
export * from './peertube-live-display'
|
export * from './peertube-live-display'
|
||||||
export * from './storyboard-plugin'
|
export * from './storyboard-plugin'
|
||||||
export * from './theater-button'
|
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 ctop = Math.floor(position / columns) * -scaledHeight
|
||||||
|
|
||||||
const bgSize = `${imgWidth * scaleFactor}px ${imgHeight * scaleFactor}px`
|
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)
|
const previewHalfSize = Math.round(scaledWidth / 2)
|
||||||
let left = seekBarRect.width * seekBarX - previewHalfSize
|
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 { PluginsManager } from '@root-helpers/plugins-manager'
|
||||||
import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
|
import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
|
||||||
import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings'
|
import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings'
|
||||||
|
@ -68,6 +68,7 @@ export type PeerTubePlayerLoadOptions = {
|
||||||
}
|
}
|
||||||
|
|
||||||
videoCaptions: VideoJSCaption[]
|
videoCaptions: VideoJSCaption[]
|
||||||
|
videoChapters: VideoChapter[]
|
||||||
storyboard: VideoJSStoryboard
|
storyboard: VideoJSStoryboard
|
||||||
|
|
||||||
videoUUID: string
|
videoUUID: string
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { HlsConfig, Level } from 'hls.js'
|
import { HlsConfig, Level } from 'hls.js'
|
||||||
import videojs from 'video.js'
|
import videojs from 'video.js'
|
||||||
import { Engine } from '@peertube/p2p-media-loader-hlsjs'
|
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 { BezelsPlugin } from '../shared/bezels/bezels-plugin'
|
||||||
import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin'
|
import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin'
|
||||||
import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-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 { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
|
||||||
import { PlayerMode } from './peertube-player-options'
|
import { PlayerMode } from './peertube-player-options'
|
||||||
import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
|
import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
|
||||||
|
import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin'
|
||||||
|
|
||||||
declare module 'video.js' {
|
declare module 'video.js' {
|
||||||
|
|
||||||
|
@ -62,6 +63,8 @@ declare module 'video.js' {
|
||||||
|
|
||||||
peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin
|
peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin
|
||||||
|
|
||||||
|
chapters (options?: ChaptersOptions): ChaptersPlugin
|
||||||
|
|
||||||
upnext (options?: UpNextPluginOptions): UpNextPlugin
|
upnext (options?: UpNextPluginOptions): UpNextPlugin
|
||||||
|
|
||||||
playlist (options?: PlaylistPluginOptions): PlaylistPlugin
|
playlist (options?: PlaylistPluginOptions): PlaylistPlugin
|
||||||
|
@ -142,6 +145,10 @@ type StoryboardOptions = {
|
||||||
interval: number
|
interval: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChaptersOptions = {
|
||||||
|
chapters: VideoChapter[]
|
||||||
|
}
|
||||||
|
|
||||||
type PlaylistPluginOptions = {
|
type PlaylistPluginOptions = {
|
||||||
elements: VideoPlaylistElement[]
|
elements: VideoPlaylistElement[]
|
||||||
|
|
||||||
|
@ -161,6 +168,10 @@ type UpNextPluginOptions = {
|
||||||
isSuspended: () => boolean
|
isSuspended: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProgressBarMarkerComponentOptions = {
|
||||||
|
timecode: number
|
||||||
|
}
|
||||||
|
|
||||||
type NextPreviousVideoButtonOptions = {
|
type NextPreviousVideoButtonOptions = {
|
||||||
type: 'next' | 'previous'
|
type: 'next' | 'previous'
|
||||||
handler?: () => void
|
handler?: () => void
|
||||||
|
@ -273,6 +284,7 @@ export {
|
||||||
NextPreviousVideoButtonOptions,
|
NextPreviousVideoButtonOptions,
|
||||||
ResolutionUpdateData,
|
ResolutionUpdateData,
|
||||||
AutoResolutionUpdateData,
|
AutoResolutionUpdateData,
|
||||||
|
ProgressBarMarkerComponentOptions,
|
||||||
PlaylistPluginOptions,
|
PlaylistPluginOptions,
|
||||||
MetricsPluginOptions,
|
MetricsPluginOptions,
|
||||||
VideoJSCaption,
|
VideoJSCaption,
|
||||||
|
@ -284,5 +296,6 @@ export {
|
||||||
UpNextPluginOptions,
|
UpNextPluginOptions,
|
||||||
LoadedQualityData,
|
LoadedQualityData,
|
||||||
StoryboardOptions,
|
StoryboardOptions,
|
||||||
|
ChaptersOptions,
|
||||||
PeerTubeLinkButtonOptions
|
PeerTubeLinkButtonOptions
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,16 @@
|
||||||
@use '_mixins' as *;
|
@use '_mixins' as *;
|
||||||
@use './_player-variables' 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 {
|
.video-js.vjs-peertube-skin .vjs-control-bar {
|
||||||
z-index: 100;
|
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 {
|
const {
|
||||||
videoResponse,
|
videoResponse,
|
||||||
captionsPromise,
|
captionsPromise,
|
||||||
|
chaptersPromise,
|
||||||
storyboardsPromise
|
storyboardsPromise
|
||||||
} = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword })
|
} = 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) {
|
} catch (err) {
|
||||||
|
|
||||||
if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
|
if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
|
||||||
|
@ -210,9 +211,10 @@ export class PeerTubeEmbed {
|
||||||
videoResponse: Response
|
videoResponse: Response
|
||||||
storyboardsPromise: Promise<Response>
|
storyboardsPromise: Promise<Response>
|
||||||
captionsPromise: Promise<Response>
|
captionsPromise: Promise<Response>
|
||||||
|
chaptersPromise: Promise<Response>
|
||||||
forceAutoplay: boolean
|
forceAutoplay: boolean
|
||||||
}) {
|
}) {
|
||||||
const { videoResponse, captionsPromise, storyboardsPromise, forceAutoplay } = options
|
const { videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay } = options
|
||||||
|
|
||||||
const videoInfoPromise = videoResponse.json()
|
const videoInfoPromise = videoResponse.json()
|
||||||
.then(async (videoInfo: VideoDetails) => {
|
.then(async (videoInfo: VideoDetails) => {
|
||||||
|
@ -233,11 +235,13 @@ export class PeerTubeEmbed {
|
||||||
{ video, live, videoFileToken },
|
{ video, live, videoFileToken },
|
||||||
translations,
|
translations,
|
||||||
captionsResponse,
|
captionsResponse,
|
||||||
|
chaptersResponse,
|
||||||
storyboardsResponse
|
storyboardsResponse
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
videoInfoPromise,
|
videoInfoPromise,
|
||||||
this.translationsPromise,
|
this.translationsPromise,
|
||||||
captionsPromise,
|
captionsPromise,
|
||||||
|
chaptersPromise,
|
||||||
storyboardsPromise,
|
storyboardsPromise,
|
||||||
this.buildPlayerIfNeeded()
|
this.buildPlayerIfNeeded()
|
||||||
])
|
])
|
||||||
|
@ -260,6 +264,7 @@ export class PeerTubeEmbed {
|
||||||
const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({
|
const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({
|
||||||
video,
|
video,
|
||||||
captionsResponse,
|
captionsResponse,
|
||||||
|
chaptersResponse,
|
||||||
translations,
|
translations,
|
||||||
|
|
||||||
storyboardsResponse,
|
storyboardsResponse,
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Storyboard,
|
Storyboard,
|
||||||
Video,
|
Video,
|
||||||
VideoCaption,
|
VideoCaption,
|
||||||
|
VideoChapter,
|
||||||
VideoDetails,
|
VideoDetails,
|
||||||
VideoPlaylistElement,
|
VideoPlaylistElement,
|
||||||
VideoState,
|
VideoState,
|
||||||
|
@ -199,6 +200,8 @@ export class PlayerOptionsBuilder {
|
||||||
|
|
||||||
storyboardsResponse: Response
|
storyboardsResponse: Response
|
||||||
|
|
||||||
|
chaptersResponse: Response
|
||||||
|
|
||||||
live?: LiveVideo
|
live?: LiveVideo
|
||||||
|
|
||||||
alreadyPlayed: boolean
|
alreadyPlayed: boolean
|
||||||
|
@ -229,12 +232,14 @@ export class PlayerOptionsBuilder {
|
||||||
forceAutoplay,
|
forceAutoplay,
|
||||||
playlist,
|
playlist,
|
||||||
live,
|
live,
|
||||||
storyboardsResponse
|
storyboardsResponse,
|
||||||
|
chaptersResponse
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const [ videoCaptions, storyboard ] = await Promise.all([
|
const [ videoCaptions, storyboard, chapters ] = await Promise.all([
|
||||||
this.buildCaptions(captionsResponse, translations),
|
this.buildCaptions(captionsResponse, translations),
|
||||||
this.buildStoryboard(storyboardsResponse)
|
this.buildStoryboard(storyboardsResponse),
|
||||||
|
this.buildChapters(chaptersResponse)
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -248,6 +253,7 @@ export class PlayerOptionsBuilder {
|
||||||
subtitle: this.subtitle,
|
subtitle: this.subtitle,
|
||||||
|
|
||||||
storyboard,
|
storyboard,
|
||||||
|
videoChapters: chapters,
|
||||||
|
|
||||||
startTime: playlist
|
startTime: playlist
|
||||||
? playlist.playlistTracker.getCurrentElement().startTimestamp
|
? 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?: {
|
private buildPlaylistOptions (options?: {
|
||||||
playlistTracker: PlaylistTracker
|
playlistTracker: PlaylistTracker
|
||||||
playNext: () => any
|
playNext: () => any
|
||||||
|
|
|
@ -36,9 +36,10 @@ export class VideoFetcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword })
|
const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword })
|
||||||
|
const chaptersPromise = this.loadVideoChapters({ videoId, videoPassword })
|
||||||
const storyboardsPromise = this.loadStoryboards(videoId)
|
const storyboardsPromise = this.loadStoryboards(videoId)
|
||||||
|
|
||||||
return { captionsPromise, storyboardsPromise, videoResponse }
|
return { captionsPromise, chaptersPromise, storyboardsPromise, videoResponse }
|
||||||
}
|
}
|
||||||
|
|
||||||
loadLive (video: VideoDetails) {
|
loadLive (video: VideoDetails) {
|
||||||
|
@ -64,6 +65,10 @@ export class VideoFetcher {
|
||||||
return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword)
|
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) {
|
private getVideoUrl (id: string) {
|
||||||
return window.location.origin + '/api/v1/videos/' + id
|
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 a of array1) {
|
||||||
for (const b of array2) {
|
for (const b of array2) {
|
||||||
if (a === b) return a
|
if (a === b) return a
|
||||||
|
@ -9,19 +9,19 @@ function findCommonElement <T> (array1: T[], array2: T[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid conflict with other toArray() functions
|
// 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
|
if (Array.isArray(element)) return element
|
||||||
|
|
||||||
return [ element ]
|
return [ element ]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid conflict with other uniq() functions
|
// Avoid conflict with other uniq() functions
|
||||||
function uniqify <T> (elements: T[]) {
|
export function uniqify <T> (elements: T[]) {
|
||||||
return Array.from(new Set(elements))
|
return Array.from(new Set(elements))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thanks: https://stackoverflow.com/a/12646864
|
// Thanks: https://stackoverflow.com/a/12646864
|
||||||
function shuffle <T> (elements: T[]) {
|
export function shuffle <T> (elements: T[]) {
|
||||||
const shuffled = [ ...elements ]
|
const shuffled = [ ...elements ]
|
||||||
|
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
@ -33,9 +33,13 @@ function shuffle <T> (elements: T[]) {
|
||||||
return shuffled
|
return shuffled
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export function sortBy (obj: any[], key1: string, key2?: string) {
|
||||||
uniqify,
|
return obj.sort((a, b) => {
|
||||||
findCommonElement,
|
const elem1 = key2 ? a[key1][key2] : a[key1]
|
||||||
shuffle,
|
const elem2 = key2 ? b[key1][key2] : b[key1]
|
||||||
arrayify
|
|
||||||
|
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) {
|
function timeToInt (time: number | string) {
|
||||||
if (!time) return 0
|
if (!time) return 0
|
||||||
if (typeof time === 'number') return time
|
if (typeof time === 'number') return time
|
||||||
|
|
||||||
const reg = /^((\d+)[h:])?((\d+)[m:])?((\d+)s?)?$/
|
const reg = new RegExp(`^${timecodeRegexString}$`)
|
||||||
const matches = time.match(reg)
|
const matches = time.match(reg)
|
||||||
|
|
||||||
if (!matches) return 0
|
if (!matches) return 0
|
||||||
|
|
|
@ -5,3 +5,4 @@ export * from './plugins/index.js'
|
||||||
export * from './renderer/index.js'
|
export * from './renderer/index.js'
|
||||||
export * from './users/index.js'
|
export * from './users/index.js'
|
||||||
export * from './videos/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) {
|
function ffprobePromise (path: string) {
|
||||||
return new Promise<FfprobeData>((res, rej) => {
|
return new Promise<FfprobeData>((res, rej) => {
|
||||||
ffmpeg.ffprobe(path, (err, data) => {
|
ffmpeg.ffprobe(path, [ '-show_chapters' ], (err, data) => {
|
||||||
if (err) return rej(err)
|
if (err) return rej(err)
|
||||||
|
|
||||||
return res(data)
|
return res(data)
|
||||||
|
@ -168,10 +168,27 @@ async function getVideoStream (path: string, existingProbe?: FfprobeData) {
|
||||||
return metadata.streams.find(s => s.codec_type === 'video')
|
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 {
|
export {
|
||||||
getVideoStreamDimensionsInfo,
|
getVideoStreamDimensionsInfo,
|
||||||
|
getChaptersFromContainer,
|
||||||
getMaxAudioBitrate,
|
getMaxAudioBitrate,
|
||||||
getVideoStream,
|
getVideoStream,
|
||||||
getVideoStreamDuration,
|
getVideoStreamDuration,
|
||||||
|
|
|
@ -13,4 +13,5 @@ export type ContextType =
|
||||||
'Flag' |
|
'Flag' |
|
||||||
'Actor' |
|
'Actor' |
|
||||||
'Collection' |
|
'Collection' |
|
||||||
'WatchAction'
|
'WatchAction' |
|
||||||
|
'Chapters'
|
||||||
|
|
|
@ -4,6 +4,7 @@ export * from './cache-file-object.js'
|
||||||
export * from './common-objects.js'
|
export * from './common-objects.js'
|
||||||
export * from './playlist-element-object.js'
|
export * from './playlist-element-object.js'
|
||||||
export * from './playlist-object.js'
|
export * from './playlist-object.js'
|
||||||
|
export * from './video-chapters-object.js'
|
||||||
export * from './video-comment-object.js'
|
export * from './video-comment-object.js'
|
||||||
export * from './video-object.js'
|
export * from './video-object.js'
|
||||||
export * from './watch-action-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
|
dislikes: string
|
||||||
shares: string
|
shares: string
|
||||||
comments: string
|
comments: string
|
||||||
|
hasParts: string
|
||||||
|
|
||||||
attributedTo: ActivityPubAttributedTo[]
|
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 './stats/index.js'
|
||||||
export * from './transcoding/index.js'
|
export * from './transcoding/index.js'
|
||||||
export * from './channel-sync/index.js'
|
export * from './channel-sync/index.js'
|
||||||
|
export * from './chapter/index.js'
|
||||||
|
|
||||||
export * from './nsfw-policy.type.js'
|
export * from './nsfw-policy.type.js'
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,7 @@ import {
|
||||||
ChangeOwnershipCommand,
|
ChangeOwnershipCommand,
|
||||||
ChannelsCommand,
|
ChannelsCommand,
|
||||||
ChannelSyncsCommand,
|
ChannelSyncsCommand,
|
||||||
|
ChaptersCommand,
|
||||||
CommentsCommand,
|
CommentsCommand,
|
||||||
HistoryCommand,
|
HistoryCommand,
|
||||||
ImportsCommand,
|
ImportsCommand,
|
||||||
|
@ -152,6 +153,7 @@ export class PeerTubeServer {
|
||||||
videoPasswords?: VideoPasswordsCommand
|
videoPasswords?: VideoPasswordsCommand
|
||||||
|
|
||||||
storyboard?: StoryboardCommand
|
storyboard?: StoryboardCommand
|
||||||
|
chapters?: ChaptersCommand
|
||||||
|
|
||||||
runners?: RunnersCommand
|
runners?: RunnersCommand
|
||||||
runnerRegistrationTokens?: RunnerRegistrationTokensCommand
|
runnerRegistrationTokens?: RunnerRegistrationTokensCommand
|
||||||
|
@ -442,6 +444,7 @@ export class PeerTubeServer {
|
||||||
this.registrations = new RegistrationsCommand(this)
|
this.registrations = new RegistrationsCommand(this)
|
||||||
|
|
||||||
this.storyboard = new StoryboardCommand(this)
|
this.storyboard = new StoryboardCommand(this)
|
||||||
|
this.chapters = new ChaptersCommand(this)
|
||||||
|
|
||||||
this.runners = new RunnersCommand(this)
|
this.runners = new RunnersCommand(this)
|
||||||
this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(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 './change-ownership-command.js'
|
||||||
export * from './channels.js'
|
export * from './channels.js'
|
||||||
export * from './channels-command.js'
|
export * from './channels-command.js'
|
||||||
|
export * from './chapters-command.js'
|
||||||
export * from './channel-syncs-command.js'
|
export * from './channel-syncs-command.js'
|
||||||
export * from './comments-command.js'
|
export * from './comments-command.js'
|
||||||
export * from './history-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-captions.js'
|
||||||
import './video-channel-syncs.js'
|
import './video-channel-syncs.js'
|
||||||
import './video-channels.js'
|
import './video-channels.js'
|
||||||
|
import './video-chapters.js'
|
||||||
import './video-comments.js'
|
import './video-comments.js'
|
||||||
import './video-files.js'
|
import './video-files.js'
|
||||||
import './video-imports.js'
|
import './video-imports.js'
|
||||||
|
|
|
@ -31,15 +31,7 @@ describe('Test video captions API validator', function () {
|
||||||
|
|
||||||
video = await server.videos.upload()
|
video = await server.videos.upload()
|
||||||
privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } })
|
privateVideo = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PRIVATE } })
|
||||||
|
userAccessToken = await server.users.generateUserAndToken('user1')
|
||||||
{
|
|
||||||
const user = {
|
|
||||||
username: 'user1',
|
|
||||||
password: 'my super password'
|
|
||||||
}
|
|
||||||
await server.users.create({ username: user.username, password: user.password })
|
|
||||||
userAccessToken = await server.login.getAccessToken(user)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('When adding video caption', function () {
|
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
|
// We accept any file now
|
||||||
// it('Should fail with an invalid captionfile extension', async function () {
|
// it('Should fail with an invalid captionfile extension', async function () {
|
||||||
// const attaches = {
|
// 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-captions.js'
|
||||||
import './video-change-ownership.js'
|
import './video-change-ownership.js'
|
||||||
import './video-channels.js'
|
import './video-channels.js'
|
||||||
|
import './video-chapters.js'
|
||||||
import './channel-import-videos.js'
|
import './channel-import-videos.js'
|
||||||
import './video-channel-syncs.js'
|
import './video-channel-syncs.js'
|
||||||
import './video-comments.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 { expect } from 'chai'
|
||||||
import snakeCase from 'lodash-es/snakeCase.js'
|
import snakeCase from 'lodash-es/snakeCase.js'
|
||||||
import validator from 'validator'
|
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 { VideoResolution } from '@peertube/peertube-models'
|
||||||
import { objectConverter, parseBytes, parseDurationToMs, parseSemVersion } from '@peertube/peertube-server/server/helpers/core-utils.js'
|
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)
|
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',
|
peertube_short: 'https://peertube2.cpy.re/w/3fbif9S3WmtTP8gGsC5HBd',
|
||||||
|
|
||||||
youtube: 'https://www.youtube.com/watch?v=msX3jv1XdvM',
|
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,
|
* 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',
|
goodVideo: 'https://download.cpy.re/peertube/good_video.mp4',
|
||||||
goodVideo720: 'https://download.cpy.re/peertube/good_video_720.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'
|
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> = {
|
export type DeepOmitArray<T extends any[], K> = {
|
||||||
[P in keyof T]: DeepOmit<T[P], 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 cors from 'cors'
|
||||||
import express from 'express'
|
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 { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js'
|
||||||
import { getContextFilter } from '@server/lib/activitypub/context.js'
|
import { getContextFilter } from '@server/lib/activitypub/context.js'
|
||||||
import { getServerActor } from '@server/models/application/application.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 { buildCreateActivity } from '../../lib/activitypub/send/send-create.js'
|
||||||
import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js'
|
import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js'
|
||||||
import {
|
import {
|
||||||
|
getLocalVideoChaptersActivityPubUrl,
|
||||||
getLocalVideoCommentsActivityPubUrl,
|
getLocalVideoCommentsActivityPubUrl,
|
||||||
getLocalVideoDislikesActivityPubUrl,
|
getLocalVideoDislikesActivityPubUrl,
|
||||||
getLocalVideoLikesActivityPubUrl,
|
getLocalVideoLikesActivityPubUrl,
|
||||||
getLocalVideoSharesActivityPubUrl
|
getLocalVideoSharesActivityPubUrl
|
||||||
} from '../../lib/activitypub/url.js'
|
} from '../../lib/activitypub/url.js'
|
||||||
import { cacheRoute } from '../../middlewares/cache/cache.js'
|
import {
|
||||||
|
apVideoChaptersSetCacheKey,
|
||||||
|
buildAPVideoChaptersGroupsCache,
|
||||||
|
cacheRoute,
|
||||||
|
cacheRouteFactory
|
||||||
|
} from '../../middlewares/cache/cache.js'
|
||||||
import {
|
import {
|
||||||
activityPubRateLimiter,
|
activityPubRateLimiter,
|
||||||
asyncMiddleware,
|
asyncMiddleware,
|
||||||
|
@ -42,6 +55,8 @@ import { VideoCommentModel } from '../../models/video/video-comment.js'
|
||||||
import { VideoPlaylistModel } from '../../models/video/video-playlist.js'
|
import { VideoPlaylistModel } from '../../models/video/video-playlist.js'
|
||||||
import { VideoShareModel } from '../../models/video/video-share.js'
|
import { VideoShareModel } from '../../models/video/video-share.js'
|
||||||
import { activityPubResponse } from './utils.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()
|
const activityPubClientRouter = express.Router()
|
||||||
activityPubClientRouter.use(cors())
|
activityPubClientRouter.use(cors())
|
||||||
|
@ -145,6 +160,27 @@ activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity
|
||||||
asyncMiddleware(videoCommentController)
|
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(
|
activityPubClientRouter.get(
|
||||||
[ '/video-channels/:nameWithHost', '/video-channels/:nameWithHost/videos', '/c/:nameWithHost', '/c/:nameWithHost/videos' ],
|
[ '/video-channels/:nameWithHost', '/video-channels/:nameWithHost/videos', '/c/:nameWithHost', '/c/:nameWithHost/videos' ],
|
||||||
executeIfActivityPub,
|
executeIfActivityPub,
|
||||||
|
@ -390,6 +426,31 @@ async function videoCommentController (req: express.Request, res: express.Respon
|
||||||
return activityPubResponse(activityPubContextify(videoCommentObject, 'Comment', getContextFilter()), res)
|
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) {
|
async function videoRedundancyController (req: express.Request, res: express.Response) {
|
||||||
const videoRedundancy = res.locals.videoRedundancy
|
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 { updateRouter } from './update.js'
|
||||||
import { uploadRouter } from './upload.js'
|
import { uploadRouter } from './upload.js'
|
||||||
import { viewRouter } from './view.js'
|
import { viewRouter } from './view.js'
|
||||||
|
import { videoChaptersRouter } from './chapters.js'
|
||||||
|
|
||||||
const auditLogger = auditLoggerFactory('videos')
|
const auditLogger = auditLoggerFactory('videos')
|
||||||
const videosRouter = express.Router()
|
const videosRouter = express.Router()
|
||||||
|
@ -73,6 +74,7 @@ videosRouter.use('/', tokenRouter)
|
||||||
videosRouter.use('/', videoPasswordRouter)
|
videosRouter.use('/', videoPasswordRouter)
|
||||||
videosRouter.use('/', storyboardRouter)
|
videosRouter.use('/', storyboardRouter)
|
||||||
videosRouter.use('/', videoSourceRouter)
|
videosRouter.use('/', videoSourceRouter)
|
||||||
|
videosRouter.use('/', videoChaptersRouter)
|
||||||
|
|
||||||
videosRouter.get('/categories',
|
videosRouter.get('/categories',
|
||||||
openapiOperationDoc({ operationId: 'getCategories' }),
|
openapiOperationDoc({ operationId: 'getCategories' }),
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist.js'
|
||||||
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js'
|
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js'
|
||||||
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
|
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
|
||||||
import { VideoModel } from '../../../models/video/video.js'
|
import { VideoModel } from '../../../models/video/video.js'
|
||||||
|
import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('api', 'video')
|
const lTags = loggerTagsFactory('api', 'video')
|
||||||
const auditLogger = auditLoggerFactory('videos')
|
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
|
// Refresh video since thumbnails to prevent concurrent updates
|
||||||
const video = await VideoModel.loadFull(videoFromReq.id, t)
|
const video = await VideoModel.loadFull(videoFromReq.id, t)
|
||||||
|
|
||||||
|
const oldDescription = video.description
|
||||||
const oldVideoChannel = video.VideoChannel
|
const oldVideoChannel = video.VideoChannel
|
||||||
|
|
||||||
const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes<VideoModel>)[] = [
|
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?
|
// Schedule an update in the future?
|
||||||
await updateSchedule(videoInstanceUpdated, videoInfoToUpdate, t)
|
await updateSchedule(videoInstanceUpdated, videoInfoToUpdate, t)
|
||||||
|
|
||||||
|
if (oldDescription !== video.description) {
|
||||||
|
await replaceChaptersFromDescriptionIfNeeded({
|
||||||
|
newDescription: videoInstanceUpdated.description,
|
||||||
|
transaction: t,
|
||||||
|
video,
|
||||||
|
oldDescription
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
await autoBlacklistVideoIfNeeded({
|
await autoBlacklistVideoIfNeeded({
|
||||||
video: videoInstanceUpdated,
|
video: videoInstanceUpdated,
|
||||||
user: res.locals.oauth.token.User,
|
user: res.locals.oauth.token.User,
|
||||||
|
|
|
@ -34,6 +34,8 @@ import {
|
||||||
} from '../../../middlewares/index.js'
|
} from '../../../middlewares/index.js'
|
||||||
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
|
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
|
||||||
import { VideoModel } from '../../../models/video/video.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 lTags = loggerTagsFactory('api', 'video')
|
||||||
const auditLogger = auditLoggerFactory('videos')
|
const auditLogger = auditLoggerFactory('videos')
|
||||||
|
@ -143,6 +145,9 @@ async function addVideo (options: {
|
||||||
const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' })
|
const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' })
|
||||||
const originalFilename = videoPhysicalFile.originalname
|
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
|
// Move physical file
|
||||||
const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
|
const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
|
||||||
await move(videoPhysicalFile.path, destination)
|
await move(videoPhysicalFile.path, destination)
|
||||||
|
@ -188,6 +193,10 @@ async function addVideo (options: {
|
||||||
}, sequelizeOptions)
|
}, sequelizeOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!await replaceChaptersFromDescriptionIfNeeded({ newDescription: video.description, video, transaction: t })) {
|
||||||
|
await replaceChapters({ video, chapters: containerChapters, transaction: t })
|
||||||
|
}
|
||||||
|
|
||||||
await autoBlacklistVideoIfNeeded({
|
await autoBlacklistVideoIfNeeded({
|
||||||
video,
|
video,
|
||||||
user,
|
user,
|
||||||
|
|
|
@ -79,6 +79,8 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
|
||||||
|
|
||||||
uploadDate: 'sc:uploadDate',
|
uploadDate: 'sc:uploadDate',
|
||||||
|
|
||||||
|
hasParts: 'sc:hasParts',
|
||||||
|
|
||||||
views: {
|
views: {
|
||||||
'@type': 'sc:Number',
|
'@type': 'sc:Number',
|
||||||
'@id': 'pt:views'
|
'@id': 'pt:views'
|
||||||
|
@ -195,7 +197,14 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
|
||||||
Announce: buildContext(),
|
Announce: buildContext(),
|
||||||
Comment: buildContext(),
|
Comment: buildContext(),
|
||||||
Delete: 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) {
|
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 { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../../initializers/constants.js'
|
||||||
import { peertubeTruncate } from '../core-utils.js'
|
import { peertubeTruncate } from '../core-utils.js'
|
||||||
import { isUrlValid } from '../custom-validators/activitypub/misc.js'
|
import { isUrlValid } from '../custom-validators/activitypub/misc.js'
|
||||||
|
import { isArray } from '../custom-validators/misc.js'
|
||||||
|
|
||||||
export type YoutubeDLInfo = {
|
export type YoutubeDLInfo = {
|
||||||
name?: string
|
name?: string
|
||||||
|
@ -16,6 +17,11 @@ export type YoutubeDLInfo = {
|
||||||
webpageUrl?: string
|
webpageUrl?: string
|
||||||
|
|
||||||
urls?: string[]
|
urls?: string[]
|
||||||
|
|
||||||
|
chapters?: {
|
||||||
|
timecode: number
|
||||||
|
title: string
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class YoutubeDLInfoBuilder {
|
export class YoutubeDLInfoBuilder {
|
||||||
|
@ -83,7 +89,10 @@ export class YoutubeDLInfoBuilder {
|
||||||
urls: this.buildAvailableUrl(obj),
|
urls: this.buildAvailableUrl(obj),
|
||||||
originallyPublishedAtWithoutTime: this.buildOriginallyPublishedAt(obj),
|
originallyPublishedAtWithoutTime: this.buildOriginallyPublishedAt(obj),
|
||||||
ext: obj.ext,
|
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: {
|
VIDEO_PASSWORD: {
|
||||||
LENGTH: { min: 2, max: 100 }
|
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 { VideoModel } from '../models/video/video.js'
|
||||||
import { VideoViewModel } from '../models/view/video-view.js'
|
import { VideoViewModel } from '../models/view/video-view.js'
|
||||||
import { CONFIG } from './config.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
|
pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string
|
||||||
|
|
||||||
|
@ -137,6 +138,7 @@ async function initDatabaseModels (silent: boolean) {
|
||||||
VideoShareModel,
|
VideoShareModel,
|
||||||
VideoFileModel,
|
VideoFileModel,
|
||||||
VideoSourceModel,
|
VideoSourceModel,
|
||||||
|
VideoChapterModel,
|
||||||
VideoCaptionModel,
|
VideoCaptionModel,
|
||||||
VideoBlacklistModel,
|
VideoBlacklistModel,
|
||||||
VideoTagModel,
|
VideoTagModel,
|
||||||
|
|
|
@ -80,6 +80,10 @@ function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) {
|
||||||
return video.url + '/comments'
|
return video.url + '/comments'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
|
||||||
|
return video.url + '/chapters'
|
||||||
|
}
|
||||||
|
|
||||||
function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
|
function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
|
||||||
return video.url + '/likes'
|
return video.url + '/likes'
|
||||||
}
|
}
|
||||||
|
@ -167,6 +171,7 @@ export {
|
||||||
getDeleteActivityPubUrl,
|
getDeleteActivityPubUrl,
|
||||||
getLocalVideoSharesActivityPubUrl,
|
getLocalVideoSharesActivityPubUrl,
|
||||||
getLocalVideoCommentsActivityPubUrl,
|
getLocalVideoCommentsActivityPubUrl,
|
||||||
|
getLocalVideoChaptersActivityPubUrl,
|
||||||
getLocalVideoLikesActivityPubUrl,
|
getLocalVideoLikesActivityPubUrl,
|
||||||
getLocalVideoDislikesActivityPubUrl,
|
getLocalVideoDislikesActivityPubUrl,
|
||||||
getLocalVideoViewerActivityPubUrl,
|
getLocalVideoViewerActivityPubUrl,
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
import { CreationAttributes, Transaction } from 'sequelize'
|
import { CreationAttributes, Transaction } from 'sequelize'
|
||||||
import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType_Type } from '@peertube/peertube-models'
|
import {
|
||||||
import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils.js'
|
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 { logger, LoggerTagsFn } from '@server/helpers/logger.js'
|
||||||
import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js'
|
import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js'
|
||||||
import { setVideoTags } from '@server/lib/video.js'
|
import { setVideoTags } from '@server/lib/video.js'
|
||||||
|
@ -29,6 +35,10 @@ import {
|
||||||
getThumbnailFromIcons
|
getThumbnailFromIcons
|
||||||
} from './object-to-model-attributes.js'
|
} from './object-to-model-attributes.js'
|
||||||
import { getTrackerUrls, setVideoTrackers } from './trackers.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 {
|
export abstract class APVideoAbstractBuilder {
|
||||||
protected abstract videoObject: VideoObject
|
protected abstract videoObject: VideoObject
|
||||||
|
@ -44,7 +54,7 @@ export abstract class APVideoAbstractBuilder {
|
||||||
protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) {
|
protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) {
|
||||||
const miniatureIcon = getThumbnailFromIcons(this.videoObject)
|
const miniatureIcon = getThumbnailFromIcons(this.videoObject)
|
||||||
if (!miniatureIcon) {
|
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
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -138,6 +148,26 @@ export abstract class APVideoAbstractBuilder {
|
||||||
video.VideoFiles = await Promise.all(upsertTasks)
|
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) {
|
protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) {
|
||||||
const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject)
|
const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject)
|
||||||
const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
|
const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
|
||||||
|
|
|
@ -60,6 +60,8 @@ export class APVideoCreator extends APVideoAbstractBuilder {
|
||||||
return { autoBlacklisted, videoCreated }
|
return { autoBlacklisted, videoCreated }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await this.updateChaptersOutsideTransaction(videoCreated)
|
||||||
|
|
||||||
return { autoBlacklisted, videoCreated }
|
return { autoBlacklisted, videoCreated }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,6 +77,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
|
||||||
|
|
||||||
await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
|
await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
|
||||||
|
|
||||||
|
await this.updateChaptersOutsideTransaction(videoUpdated)
|
||||||
|
|
||||||
await autoBlacklistVideoIfNeeded({
|
await autoBlacklistVideoIfNeeded({
|
||||||
video: videoUpdated,
|
video: videoUpdated,
|
||||||
user: undefined,
|
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'
|
import { EventEmitter } from 'events'
|
||||||
|
|
||||||
export interface PeerTubeInternalEvents {
|
export interface PeerTubeInternalEvents {
|
||||||
|
@ -9,6 +9,8 @@ export interface PeerTubeInternalEvents {
|
||||||
'channel-created': (options: { channel: MChannel }) => void
|
'channel-created': (options: { channel: MChannel }) => void
|
||||||
'channel-updated': (options: { channel: MChannel }) => void
|
'channel-updated': (options: { channel: MChannel }) => void
|
||||||
'channel-deleted': (options: { channel: MChannel }) => void
|
'channel-deleted': (options: { channel: MChannel }) => void
|
||||||
|
|
||||||
|
'chapters-updated': (options: { video: MVideoImmutable }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
declare interface InternalEventEmitter {
|
declare interface InternalEventEmitter {
|
||||||
|
|
|
@ -32,6 +32,7 @@ import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImpo
|
||||||
import { getLowercaseExtension } from '@peertube/peertube-node-utils'
|
import { getLowercaseExtension } from '@peertube/peertube-node-utils'
|
||||||
import {
|
import {
|
||||||
ffprobePromise,
|
ffprobePromise,
|
||||||
|
getChaptersFromContainer,
|
||||||
getVideoStreamDimensionsInfo,
|
getVideoStreamDimensionsInfo,
|
||||||
getVideoStreamDuration,
|
getVideoStreamDuration,
|
||||||
getVideoStreamFPS,
|
getVideoStreamFPS,
|
||||||
|
@ -49,6 +50,7 @@ import { federateVideoIfNeeded } from '../../activitypub/videos/index.js'
|
||||||
import { Notifier } from '../../notifier/index.js'
|
import { Notifier } from '../../notifier/index.js'
|
||||||
import { generateLocalVideoMiniature } from '../../thumbnail.js'
|
import { generateLocalVideoMiniature } from '../../thumbnail.js'
|
||||||
import { JobQueue } from '../job-queue.js'
|
import { JobQueue } from '../job-queue.js'
|
||||||
|
import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js'
|
||||||
|
|
||||||
async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> {
|
async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> {
|
||||||
const payload = job.data as VideoImportPayload
|
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 fps = await getVideoStreamFPS(tempVideoPath, probe)
|
||||||
const duration = await getVideoStreamDuration(tempVideoPath, probe)
|
const duration = await getVideoStreamDuration(tempVideoPath, probe)
|
||||||
|
|
||||||
|
const containerChapters = await getChaptersFromContainer(tempVideoPath, probe)
|
||||||
|
|
||||||
// Prepare video file object for creation in database
|
// Prepare video file object for creation in database
|
||||||
const fileExt = getLowercaseExtension(tempVideoPath)
|
const fileExt = getLowercaseExtension(tempVideoPath)
|
||||||
const videoFileData = {
|
const videoFileData = {
|
||||||
|
@ -228,6 +232,8 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
|
||||||
if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t)
|
if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t)
|
||||||
if (previewModel) await video.addAndSaveThumbnail(previewModel, 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)
|
// Now we can federate the video (reload from database, we need more attributes)
|
||||||
const videoForFederation = await VideoModel.loadFull(video.uuid, t)
|
const videoForFederation = await VideoModel.loadFull(video.uuid, t)
|
||||||
await federateVideoIfNeeded(videoForFederation, true, 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'
|
} from '@server/types/models/index.js'
|
||||||
import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
|
import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
|
||||||
import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js'
|
import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js'
|
||||||
|
import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js'
|
||||||
|
|
||||||
class YoutubeDlImportError extends Error {
|
class YoutubeDlImportError extends Error {
|
||||||
code: YoutubeDlImportError.CODE
|
code: YoutubeDlImportError.CODE
|
||||||
|
@ -227,6 +228,29 @@ async function buildYoutubeDLImport (options: {
|
||||||
videoPasswords: importDataOverride.videoPasswords
|
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
|
// Get video subtitles
|
||||||
await processYoutubeSubtitles(youtubeDL, targetUrl, video.id)
|
await processYoutubeSubtitles(youtubeDL, targetUrl, video.id)
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import express from 'express'
|
||||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||||
import { ApiCache, APICacheOptions } from './shared/index.js'
|
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)
|
const instance = new ApiCache(defaultOptions)
|
||||||
|
|
||||||
return instance.buildMiddleware(duration)
|
return instance.buildMiddleware(duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheRouteFactory (options: APICacheOptions) {
|
export function cacheRouteFactory (options: APICacheOptions = {}) {
|
||||||
const instance = new ApiCache({ ...defaultOptions, ...options })
|
const instance = new ApiCache({ ...defaultOptions, ...options })
|
||||||
|
|
||||||
return { instance, middleware: instance.buildMiddleware.bind(instance) }
|
return { instance, middleware: instance.buildMiddleware.bind(instance) }
|
||||||
|
@ -22,17 +23,36 @@ function cacheRouteFactory (options: APICacheOptions) {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function buildPodcastGroupsCache (options: {
|
export function buildPodcastGroupsCache (options: {
|
||||||
channelId: number
|
channelId: number
|
||||||
}) {
|
}) {
|
||||||
return 'podcast-feed-' + options.channelId
|
return 'podcast-feed-' + options.channelId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildAPVideoChaptersGroupsCache (options: {
|
||||||
|
videoId: number | string
|
||||||
|
}) {
|
||||||
|
return 'ap-video-chapters-' + options.videoId
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export const videoFeedsPodcastSetCacheKey = [
|
||||||
cacheRoute,
|
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
cacheRouteFactory,
|
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 { HttpStatusCode } from '@peertube/peertube-models'
|
||||||
import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js'
|
import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js'
|
||||||
import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js'
|
import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc.js'
|
||||||
import { buildPodcastGroupsCache } from '../cache/index.js'
|
|
||||||
import {
|
import {
|
||||||
areValidationErrors,
|
areValidationErrors,
|
||||||
checkCanSeeVideo,
|
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 = [
|
const videoSubscriptionFeedsValidator = [
|
||||||
|
@ -173,6 +163,5 @@ export {
|
||||||
feedsAccountOrChannelFiltersValidator,
|
feedsAccountOrChannelFiltersValidator,
|
||||||
videoFeedsPodcastValidator,
|
videoFeedsPodcastValidator,
|
||||||
videoSubscriptionFeedsValidator,
|
videoSubscriptionFeedsValidator,
|
||||||
videoFeedsPodcastSetCacheKey,
|
|
||||||
videoCommentsFeedsValidator
|
videoCommentsFeedsValidator
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ export * from './video-blacklist.js'
|
||||||
export * from './video-captions.js'
|
export * from './video-captions.js'
|
||||||
export * from './video-channel-sync.js'
|
export * from './video-channel-sync.js'
|
||||||
export * from './video-channels.js'
|
export * from './video-channels.js'
|
||||||
|
export * from './video-chapters.js'
|
||||||
export * from './video-comments.js'
|
export * from './video-comments.js'
|
||||||
export * from './video-files.js'
|
export * from './video-files.js'
|
||||||
export * from './video-imports.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'
|
} from '@peertube/peertube-models'
|
||||||
import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js'
|
import { MIMETYPES, WEBSERVER } from '../../../initializers/constants.js'
|
||||||
import {
|
import {
|
||||||
|
getLocalVideoChaptersActivityPubUrl,
|
||||||
getLocalVideoCommentsActivityPubUrl,
|
getLocalVideoCommentsActivityPubUrl,
|
||||||
getLocalVideoDislikesActivityPubUrl,
|
getLocalVideoDislikesActivityPubUrl,
|
||||||
getLocalVideoLikesActivityPubUrl,
|
getLocalVideoLikesActivityPubUrl,
|
||||||
|
@ -95,6 +96,7 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
|
||||||
dislikes: getLocalVideoDislikesActivityPubUrl(video),
|
dislikes: getLocalVideoDislikesActivityPubUrl(video),
|
||||||
shares: getLocalVideoSharesActivityPubUrl(video),
|
shares: getLocalVideoSharesActivityPubUrl(video),
|
||||||
comments: getLocalVideoCommentsActivityPubUrl(video),
|
comments: getLocalVideoCommentsActivityPubUrl(video),
|
||||||
|
hasParts: getLocalVideoChaptersActivityPubUrl(video),
|
||||||
|
|
||||||
attributedTo: [
|
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,
|
MActorSummaryFormattable,
|
||||||
MActorUrl
|
MActorUrl
|
||||||
} from '../actor/index.js'
|
} from '../actor/index.js'
|
||||||
import { MChannelDefault } from '../video/video-channels.js'
|
import { MChannelDefault } from '../video/video-channel.js'
|
||||||
import { MAccountBlocklistId } from './account-blocklist.js'
|
import { MAccountBlocklistId } from './account-blocklist.js'
|
||||||
|
|
||||||
type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M>
|
type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M>
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
MAccountIdActorId,
|
MAccountIdActorId,
|
||||||
MAccountUrl
|
MAccountUrl
|
||||||
} from '../account/index.js'
|
} 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'
|
import { MNotificationSetting, MNotificationSettingFormattable } from './user-notification-setting.js'
|
||||||
|
|
||||||
type Use<K extends keyof UserModel, M> = PickWith<UserModel, K, M>
|
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-caption.js'
|
||||||
export * from './video-change-ownership.js'
|
export * from './video-change-ownership.js'
|
||||||
export * from './video-channel-sync.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-comment.js'
|
||||||
export * from './video-file.js'
|
export * from './video-file.js'
|
||||||
export * from './video-import.js'
|
export * from './video-import.js'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
|
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
|
||||||
import { FunctionProperties, PickWith } from '@peertube/peertube-typescript-utils'
|
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>
|
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 { VideoPlaylistModel } from '../../../models/video/video-playlist.js'
|
||||||
import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account/index.js'
|
import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account/index.js'
|
||||||
import { MThumbnail } from './thumbnail.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>
|
type Use<K extends keyof VideoPlaylistModel, M> = PickWith<VideoPlaylistModel, K, M>
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ import {
|
||||||
MChannelFormattable,
|
MChannelFormattable,
|
||||||
MChannelHostOnly,
|
MChannelHostOnly,
|
||||||
MChannelUserId
|
MChannelUserId
|
||||||
} from './video-channels.js'
|
} from './video-channel.js'
|
||||||
import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js'
|
import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file.js'
|
||||||
import { MVideoLive } from './video-live.js'
|
import { MVideoLive } from './video-live.js'
|
||||||
import {
|
import {
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue