Add config option to keep original video file (basic first version) (#6157)

* testing not removing old file and adding columb to db

* implement feature

* remove unnecessary config changes

* use only keptOriginalFileName, change keptOriginalFileName to keptOriginalFilename for consistency with with videoFile table, slight refactor with basename()

* save original video files to dedicated directory original-video-files

* begin implementing object storage (bucket) support

---------

Co-authored-by: chagai.friedlander <chagai.friedlander@fairkom.eu>
Co-authored-by: Ian <ian.kraft@hotmail.com>
Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
chagai95 2024-03-15 15:47:18 +01:00 committed by GitHub
parent ae31e90c30
commit e57c3024f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
75 changed files with 1653 additions and 801 deletions

View File

@ -226,6 +226,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
concurrency: CONCURRENCY_VALIDATOR, concurrency: CONCURRENCY_VALIDATOR,
resolutions: {}, resolutions: {},
alwaysTranscodeOriginalResolution: null, alwaysTranscodeOriginalResolution: null,
originalFile: {
keep: null
},
hls: { hls: {
enabled: null enabled: null
}, },

View File

@ -39,7 +39,7 @@
<ng-container ngProjectAs="extra"> <ng-container ngProjectAs="extra">
<div class="callout callout-light pt-2 pb-0"> <div class="callout callout-light pt-2 pb-0">
<h3 class="callout-title" i18n>Input formats</h3> <h3 class="callout-title" i18n>Input</h3>
<div class="form-group" [ngClass]="getTranscodingDisabledClass()"> <div class="form-group" [ngClass]="getTranscodingDisabledClass()">
<my-peertube-checkbox <my-peertube-checkbox
@ -63,10 +63,21 @@
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
<div class="form-group" formGroupName="originalFile" [ngClass]="getTranscodingDisabledClass()">
<my-peertube-checkbox
inputName="transcodingOriginalFileKeep" formControlName="keep"
i18n-labelText labelText="Keep a version of the input file"
>
<ng-container ngProjectAs="description">
<div i18n>If enabled, the input file is not deleted after transcoding but moved in a dedicated folder or object storage</div>
</ng-container>
</my-peertube-checkbox>
</div>
</div> </div>
<div class="callout callout-light pt-2 mt-2 pb-0"> <div class="callout callout-light pt-2 mt-2 pb-0">
<h3 class="callout-title" i18n>Output formats</h3> <h3 class="callout-title" i18n>Output</h3>
<ng-container formGroupName="webVideos"> <ng-container formGroupName="webVideos">
<div class="form-group" [ngClass]="getTranscodingDisabledClass()"> <div class="form-group" [ngClass]="getTranscodingDisabledClass()">

View File

@ -405,7 +405,7 @@ export class VideoService {
getSource (videoId: number) { getSource (videoId: number) {
return this.authHttp return this.authHttp
.get<{ source: VideoSource }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/source') .get<VideoSource>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/source')
.pipe( .pipe(
catchError(err => { catchError(err => {
if (err.status === 404) { if (err.status === 404) {

View File

@ -16,7 +16,7 @@
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body" [ngClass]="{ 'opacity-0': !loaded }">
<div class="alert alert-warning" *ngIf="isConfidentialVideo()" i18n> <div class="alert alert-warning" *ngIf="isConfidentialVideo()" i18n>
The following link contains a private token and should not be shared with anyone. The following link contains a private token and should not be shared with anyone.
</div> </div>
@ -45,13 +45,30 @@
<!-- Video tab --> <!-- Video tab -->
<ng-container *ngIf="type === 'video'"> <ng-container *ngIf="type === 'video'">
<div ngbNav #resolutionNav="ngbNav" class="nav-tabs" [activeId]="resolutionId" (activeIdChange)="onResolutionIdChange($event)"> <div ngbNav #resolutionNav="ngbNav" class="nav-tabs" [activeId]="resolutionId" (activeIdChange)="onResolutionIdChange($event)">
<ng-container *ngFor="let file of getVideoFiles()" [ngbNavItem]="file.resolution.id">
<a ngbNavLink i18n>{{ file.resolution.label }}</a> <ng-template #rootNavContent>
<div class="nav-content">
<my-input-text [show]="true" [readonly]="true" [withCopy]="true" [withToggle]="false" [value]="getLink()"></my-input-text>
</div>
</ng-template>
<ng-container *ngIf="originalVideoFile" ngbNavItem="original">
<a ngbNavLink i18n>
<ng-container>Original file</ng-container>
<my-global-icon ngbTooltip="Other users cannot download the original file" iconName="shield"></my-global-icon>
</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="nav-content"> <ng-template [ngTemplateOutlet]="rootNavContent"></ng-template>
<my-input-text [show]="true" [readonly]="true" [withCopy]="true" [withToggle]="false" [value]="getLink()"></my-input-text> </ng-template>
</div> </ng-container>
<ng-container *ngFor="let file of getVideoFiles()" [ngbNavItem]="file.resolution.id">
<a ngbNavLink>{{ file.resolution.label }}</a>
<ng-template ngbNavContent>
<ng-template [ngTemplateOutlet]="rootNavContent"></ng-template>
</ng-template> </ng-template>
</ng-container> </ng-container>
</div> </div>
@ -60,47 +77,59 @@
<div class="advanced-filters" [ngbCollapse]="isAdvancedCustomizationCollapsed" [animation]="true"> <div class="advanced-filters" [ngbCollapse]="isAdvancedCustomizationCollapsed" [animation]="true">
<div ngbNav #navMetadata="ngbNav" class="nav-tabs nav-metadata"> <div ngbNav #navMetadata="ngbNav" class="nav-tabs nav-metadata">
<ng-template #metadataInfo let-item>
<div class="metadata-attribute">
<span>{{ item.value.label }}</span>
@if (item.value.value) {
<span>{{ item.value.value }}</span>
} @else {
<span i18n>Unknown</span>
}
</div>
</ng-template>
<ng-container ngbNavItem> <ng-container ngbNavItem>
<a ngbNavLink i18n>Format</a> <a ngbNavLink i18n>Format</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="file-metadata"> <div class="file-metadata">
<div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataFormat | keyvalue"> @for (item of videoFileMetadataFormat | keyvalue; track item) {
<span i18n class="metadata-attribute-label">{{ item.value.label }}</span> <ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
<span class="metadata-attribute-value">{{ item.value.value }}</span> }
</div>
</div> </div>
</ng-template> </ng-template>
</ng-container>
<ng-container ngbNavItem [disabled]="videoFileMetadataVideoStream === undefined"> <ng-container ngbNavItem [disabled]="videoFileMetadataVideoStream === undefined">
<a ngbNavLink i18n>Video stream</a> <a ngbNavLink i18n>Video stream</a>
<ng-template ngbNavContent>
<div class="file-metadata">
<div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataVideoStream | keyvalue">
<span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
<span class="metadata-attribute-value">{{ item.value.value }}</span>
</div>
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem [disabled]="videoFileMetadataAudioStream === undefined"> <ng-template ngbNavContent>
<a ngbNavLink i18n>Audio stream</a> <div class="file-metadata">
<ng-template ngbNavContent> @for (item of videoFileMetadataVideoStream | keyvalue; track item) {
<div class="file-metadata"> <ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
<div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataAudioStream | keyvalue"> }
<span i18n class="metadata-attribute-label">{{ item.value.label }}</span> </div>
<span class="metadata-attribute-value">{{ item.value.value }}</span> </ng-template>
</div> </ng-container>
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem [disabled]="videoFileMetadataAudioStream === undefined">
<a ngbNavLink i18n>Audio stream</a>
<ng-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataAudioStream | keyvalue; track item) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container> </ng-container>
</div> </div>
<div *ngIf="getFileMetadata()" [ngbNavOutlet]="navMetadata"></div> <div *ngIf="hasMetadata()" [ngbNavOutlet]="navMetadata"></div>
<div class="download-type"> <div [hidden]="originalVideoFile" class="download-type">
<div class="peertube-radio-container"> <div class="peertube-radio-container">
<input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct"> <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
<label i18n for="download-direct">Direct download</label> <label i18n for="download-direct">Direct download</label>
@ -121,17 +150,13 @@
<ng-container *ngIf="isAdvancedCustomizationCollapsed"> <ng-container *ngIf="isAdvancedCustomizationCollapsed">
<span class="chevron-down"></span> <span class="chevron-down"></span>
<ng-container i18n> <ng-container i18n>More information/options</ng-container>
Advanced
</ng-container>
</ng-container> </ng-container>
<ng-container *ngIf="!isAdvancedCustomizationCollapsed"> <ng-container *ngIf="!isAdvancedCustomizationCollapsed">
<span class="chevron-up"></span> <span class="chevron-up"></span>
<ng-container i18n> <ng-container i18n>Less information/options</ng-container>
Simple
</ng-container>
</ng-container> </ng-container>
</button> </button>
</ng-container> </ng-container>

View File

@ -5,6 +5,13 @@
margin-top: 30px; margin-top: 30px;
} }
my-global-icon[iconName=shield] {
@include margin-left(10px);
width: 16px;
margin-top: -3px;
}
.advanced-filters-button { .advanced-filters-button {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -53,7 +60,7 @@
display: block; display: block;
margin-bottom: 12px; margin-bottom: 12px;
.metadata-attribute-label { > span:first-child {
@include padding-right(5px); @include padding-right(5px);
min-width: 142px; min-width: 142px;
@ -61,22 +68,4 @@
color: pvar(--greyForegroundColor); color: pvar(--greyForegroundColor);
font-weight: $font-bold; font-weight: $font-bold;
} }
a.metadata-attribute-value {
@include disable-default-a-behaviour;
color: pvar(--mainForegroundColor);
&:hover {
opacity: 0.9;
}
}
&.metadata-attribute-tags {
.metadata-attribute-value:not(:nth-child(2)) {
&::before {
content: ', ';
}
}
}
} }

View File

@ -1,30 +1,31 @@
import { mapValues } from 'lodash-es' import { KeyValuePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
import { firstValueFrom } from 'rxjs'
import { tap } from 'rxjs/operators'
import { Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild } from '@angular/core' import { Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild } from '@angular/core'
import { HooksService } from '@app/core' import { FormsModule } from '@angular/forms'
import { AuthService, HooksService } from '@app/core'
import { import {
NgbCollapse,
NgbModal, NgbModal,
NgbModalRef, NgbModalRef,
NgbNav, NgbNav,
NgbNavContent,
NgbNavItem, NgbNavItem,
NgbNavLink, NgbNavLink,
NgbNavLinkBase, NgbNavLinkBase,
NgbNavContent,
NgbNavOutlet, NgbNavOutlet,
NgbCollapse NgbTooltip
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { objectKeysTyped, pick } from '@peertube/peertube-core-utils'
import { VideoCaption, VideoFile, VideoFileMetadata, VideoSource } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger' import { logger } from '@root-helpers/logger'
import { videoRequiresFileToken } from '@root-helpers/video' import { videoRequiresFileToken } from '@root-helpers/video'
import { objectKeysTyped, pick } from '@peertube/peertube-core-utils' import { mapValues } from 'lodash-es'
import { VideoCaption, VideoFile } from '@peertube/peertube-models' import { firstValueFrom, of } from 'rxjs'
import { tap } from 'rxjs/operators'
import { InputTextComponent } from '../shared-forms/input-text.component' import { InputTextComponent } from '../shared-forms/input-text.component'
import { GlobalIconComponent } from '../shared-icons/global-icon.component' import { GlobalIconComponent } from '../shared-icons/global-icon.component'
import { FormsModule } from '@angular/forms'
import { NgIf, NgFor, KeyValuePipe } from '@angular/common'
import { VideoDetails } from '../shared-main/video/video-details.model'
import { BytesPipe } from '../shared-main/angular/bytes.pipe' import { BytesPipe } from '../shared-main/angular/bytes.pipe'
import { NumberFormatterPipe } from '../shared-main/angular/number-formatter.pipe' import { NumberFormatterPipe } from '../shared-main/angular/number-formatter.pipe'
import { VideoDetails } from '../shared-main/video/video-details.model'
import { VideoFileTokenService } from '../shared-main/video/video-file-token.service' import { VideoFileTokenService } from '../shared-main/video/video-file-token.service'
import { VideoService } from '../shared-main/video/video.service' import { VideoService } from '../shared-main/video/video.service'
@ -49,7 +50,10 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } }
InputTextComponent, InputTextComponent,
NgbNavOutlet, NgbNavOutlet,
NgbCollapse, NgbCollapse,
KeyValuePipe KeyValuePipe,
NgbTooltip,
NgTemplateOutlet,
NgClass
] ]
}) })
export class VideoDownloadComponent { export class VideoDownloadComponent {
@ -59,7 +63,7 @@ export class VideoDownloadComponent {
downloadType: 'direct' | 'torrent' = 'direct' downloadType: 'direct' | 'torrent' = 'direct'
resolutionId: number | string = -1 resolutionId: number | 'original' = -1
subtitleLanguageId: string subtitleLanguageId: string
videoFileMetadataFormat: FileMetadata videoFileMetadataFormat: FileMetadata
@ -72,6 +76,10 @@ export class VideoDownloadComponent {
videoFileToken: string videoFileToken: string
originalVideoFile: VideoSource
loaded = false
private activeModal: NgbModalRef private activeModal: NgbModalRef
private bytesPipe: BytesPipe private bytesPipe: BytesPipe
@ -83,6 +91,7 @@ export class VideoDownloadComponent {
constructor ( constructor (
@Inject(LOCALE_ID) private localeId: string, @Inject(LOCALE_ID) private localeId: string,
private modalService: NgbModal, private modalService: NgbModal,
private authService: AuthService,
private videoService: VideoService, private videoService: VideoService,
private videoFileTokenService: VideoFileTokenService, private videoFileTokenService: VideoFileTokenService,
private hooks: HooksService private hooks: HooksService
@ -110,7 +119,10 @@ export class VideoDownloadComponent {
} }
show (video: VideoDetails, videoCaptions?: VideoCaption[]) { show (video: VideoDetails, videoCaptions?: VideoCaption[]) {
this.loaded = false
this.videoFileToken = undefined this.videoFileToken = undefined
this.originalVideoFile = undefined
this.video = video this.video = video
this.videoCaptions = videoCaptions this.videoCaptions = videoCaptions
@ -125,16 +137,40 @@ export class VideoDownloadComponent {
this.subtitleLanguageId = this.videoCaptions[0].language.id this.subtitleLanguageId = this.videoCaptions[0].language.id
} }
if (this.isConfidentialVideo()) { this.getOriginalVideoFileObs()
this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword }) .subscribe(source => {
.subscribe(({ token }) => this.videoFileToken = token) if (source?.fileDownloadUrl) {
} this.originalVideoFile = source
}
if (this.originalVideoFile || this.isConfidentialVideo()) {
this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword })
.subscribe(({ token }) => {
this.videoFileToken = token
this.loaded = true
})
} else {
this.loaded = true
}
})
this.activeModal.shown.subscribe(() => { this.activeModal.shown.subscribe(() => {
this.hooks.runAction('action:modal.video-download.shown', 'common') this.hooks.runAction('action:modal.video-download.shown', 'common')
}) })
} }
private getOriginalVideoFileObs () {
if (!this.authService.isLoggedIn()) return of(undefined)
const user = this.authService.getUser()
if (!this.video.isOwnerOrHasSeeAllVideosRight(user)) return of(undefined)
return this.videoService.getSource(this.video.id)
}
// ---------------------------------------------------------------------------
onClose () { onClose () {
this.video = undefined this.video = undefined
this.videoCaptions = undefined this.videoCaptions = undefined
@ -152,28 +188,29 @@ export class VideoDownloadComponent {
: this.getVideoFileLink() : this.getVideoFileLink()
} }
async onResolutionIdChange (resolutionId: number) { async onResolutionIdChange (resolutionId: number | 'original') {
this.resolutionId = resolutionId this.resolutionId = resolutionId
const videoFile = this.getVideoFile() let metadata: VideoFileMetadata
if (!videoFile.metadata) { if (this.resolutionId === 'original') {
if (!videoFile.metadataUrl) return metadata = this.originalVideoFile.metadata
} else {
const videoFile = this.getVideoFile()
if (!videoFile) return
await this.hydrateMetadataFromMetadataUrl(videoFile) if (!videoFile.metadata && videoFile.metadataUrl) {
await this.hydrateMetadataFromMetadataUrl(videoFile)
}
metadata = videoFile.metadata
} }
if (!videoFile.metadata) return if (!metadata) return
this.videoFileMetadataFormat = videoFile this.videoFileMetadataFormat = this.getMetadataFormat(metadata.format)
? this.getMetadataFormat(videoFile.metadata.format) this.videoFileMetadataVideoStream = this.getMetadataStream(metadata.streams, 'video')
: undefined this.videoFileMetadataAudioStream = this.getMetadataStream(metadata.streams, 'audio')
this.videoFileMetadataVideoStream = videoFile
? this.getMetadataStream(videoFile.metadata.streams, 'video')
: undefined
this.videoFileMetadataAudioStream = videoFile
? this.getMetadataStream(videoFile.metadata.streams, 'audio')
: undefined
} }
onSubtitleIdChange (subtitleId: string) { onSubtitleIdChange (subtitleId: string) {
@ -185,8 +222,10 @@ export class VideoDownloadComponent {
} }
getVideoFile () { getVideoFile () {
if (this.resolutionId === 'original') return undefined
const file = this.getVideoFiles() const file = this.getVideoFiles()
.find(f => f.resolution.id === this.resolutionId) .find(f => f.resolution.id === this.resolutionId)
if (!file) { if (!file) {
logger.error(`Could not find file with resolution ${this.resolutionId}`) logger.error(`Could not find file with resolution ${this.resolutionId}`)
@ -197,13 +236,17 @@ export class VideoDownloadComponent {
} }
getVideoFileLink () { getVideoFileLink () {
const file = this.getVideoFile() const suffix = this.resolutionId === 'original' || this.isConfidentialVideo()
if (!file) return ''
const suffix = this.isConfidentialVideo()
? '?videoFileToken=' + this.videoFileToken ? '?videoFileToken=' + this.videoFileToken
: '' : ''
if (this.resolutionId === 'original') {
return this.originalVideoFile.fileDownloadUrl + suffix
}
const file = this.getVideoFile()
if (!file) return ''
switch (this.downloadType) { switch (this.downloadType) {
case 'direct': case 'direct':
return file.fileDownloadUrl + suffix return file.fileDownloadUrl + suffix
@ -219,7 +262,7 @@ export class VideoDownloadComponent {
getCaption () { getCaption () {
const caption = this.getCaptions() const caption = this.getCaptions()
.find(c => c.language.id === this.subtitleLanguageId) .find(c => c.language.id === this.subtitleLanguageId)
if (!caption) { if (!caption) {
logger.error(`Cannot find caption ${this.subtitleLanguageId}`) logger.error(`Cannot find caption ${this.subtitleLanguageId}`)
@ -237,19 +280,15 @@ export class VideoDownloadComponent {
} }
isConfidentialVideo () { isConfidentialVideo () {
return videoRequiresFileToken(this.video) return this.resolutionId === 'original' || videoRequiresFileToken(this.video)
} }
switchToType (type: DownloadType) { switchToType (type: DownloadType) {
this.type = type this.type = type
} }
getFileMetadata () { hasMetadata () {
const file = this.getVideoFile() return !!this.videoFileMetadataFormat
if (!file) return undefined
return file.metadata
} }
private getMetadataFormat (format: any) { private getMetadataFormat (format: any) {
@ -282,7 +321,9 @@ export class VideoDownloadComponent {
profile: (value: string) => ({ label: $localize`Profile`, value }), profile: (value: string) => ({ label: $localize`Profile`, value }),
bit_rate: (value: number | string) => ({ bit_rate: (value: number | string) => ({
label: $localize`Bitrate`, label: $localize`Bitrate`,
value: `${this.numbersPipe.transform(+value)}bps` value: isNaN(+value)
? undefined
: `${this.numbersPipe.transform(+value)}bps`
}) })
} }

View File

@ -1,5 +1,5 @@
<svg <svg
xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 512 512" fill="currentColor"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
> >
<path d="M466.5 83.7l-192-80a48.15 48.15 0 0 0-36.9 0l-192 80C27.7 91.1 16 108.6 16 128c0 198.5 114.5 335.7 221.5 380.3 11.8 4.9 25.1 4.9 36.9 0C360.1 472.6 496 349.3 496 128c0-19.4-11.7-36.9-29.5-44.3zM256.1 446.3l-.1-381 175.9 73.3c-3.3 151.4-82.1 261.1-175.8 307.7z"></path> <path d="M466.5 83.7l-192-80a48.15 48.15 0 0 0-36.9 0l-192 80C27.7 91.1 16 108.6 16 128c0 198.5 114.5 335.7 221.5 380.3 11.8 4.9 25.1 4.9 36.9 0C360.1 472.6 496 349.3 496 128c0-19.4-11.7-36.9-29.5-44.3zM256.1 446.3l-.1-381 175.9 73.3c-3.3 151.4-82.1 261.1-175.8 307.7z"></path>

Before

Width:  |  Height:  |  Size: 486 B

After

Width:  |  Height:  |  Size: 484 B

View File

@ -152,6 +152,7 @@ storage:
avatars: 'storage/avatars/' avatars: 'storage/avatars/'
web_videos: 'storage/web-videos/' web_videos: 'storage/web-videos/'
streaming_playlists: 'storage/streaming-playlists/' streaming_playlists: 'storage/streaming-playlists/'
original_video_files: 'storage/original-video-files/'
redundancy: 'storage/redundancy/' redundancy: 'storage/redundancy/'
logs: 'storage/logs/' logs: 'storage/logs/'
previews: 'storage/previews/' previews: 'storage/previews/'
@ -238,6 +239,12 @@ object_storage:
prefix: '' prefix: ''
base_url: '' base_url: ''
# Same settings but for original video files
original_video_files:
bucket_name: 'original-video-files'
prefix: ''
base_url: ''
log: log:
level: 'info' # 'debug' | 'info' | 'warn' | 'error' level: 'info' # 'debug' | 'info' | 'warn' | 'error'
@ -526,6 +533,11 @@ video_channels:
transcoding: transcoding:
enabled: true enabled: true
original_file:
# If false the uploaded file is deleted after transcoding
# If yes it is not deleted but moved in a dedicated folder or object storage
keep: false
# Allow your users to upload .mkv, .mov, .avi, .wmv, .flv, .f4v, .3g2, .3gp, .mts, m2ts, .mxf, .nut videos # Allow your users to upload .mkv, .mov, .avi, .wmv, .flv, .f4v, .3g2, .3gp, .mts, m2ts, .mxf, .nut videos
allow_additional_extensions: true allow_additional_extensions: true
@ -844,7 +856,7 @@ services:
# Cards configuration to format video in Twitter/X # Cards configuration to format video in Twitter/X
# All other social media (Facebook, Mastodon, etc.) are supported out of the box # All other social media (Facebook, Mastodon, etc.) are supported out of the box
twitter: twitter:
# Indicates the Twitter account for the website or platform where the content was published # Indicates the Twitter/X account for the website or platform where the content was published
# This is just an information injected in HTML that is required by Twitter/X # This is just an information injected in HTML that is required by Twitter/X
username: '@Chocobozzz' username: '@Chocobozzz'

View File

@ -128,3 +128,6 @@ geo_ip:
video_studio: video_studio:
enabled: true enabled: true
transcoding:
keep_original_file: false

View File

@ -150,6 +150,7 @@ storage:
avatars: '/var/www/peertube/storage/avatars/' avatars: '/var/www/peertube/storage/avatars/'
web_videos: '/var/www/peertube/storage/web-videos/' web_videos: '/var/www/peertube/storage/web-videos/'
streaming_playlists: '/var/www/peertube/storage/streaming-playlists/' streaming_playlists: '/var/www/peertube/storage/streaming-playlists/'
original_video_files: '/var/www/peertube/storage/original-video-files/'
redundancy: '/var/www/peertube/storage/redundancy/' redundancy: '/var/www/peertube/storage/redundancy/'
logs: '/var/www/peertube/storage/logs/' logs: '/var/www/peertube/storage/logs/'
previews: '/var/www/peertube/storage/previews/' previews: '/var/www/peertube/storage/previews/'
@ -236,6 +237,12 @@ object_storage:
prefix: '' prefix: ''
base_url: '' base_url: ''
# Same settings but for original video files
original_video_files:
bucket_name: 'original-video-files'
prefix: ''
base_url: ''
log: log:
level: 'info' # 'debug' | 'info' | 'warn' | 'error' level: 'info' # 'debug' | 'info' | 'warn' | 'error'
@ -536,6 +543,11 @@ video_channels:
transcoding: transcoding:
enabled: true enabled: true
original_file:
# If false the uploaded file is deleted after transcoding
# If yes it is not deleted but moved in a dedicated folder or object storage
keep: false
# Allow your users to upload .mkv, .mov, .avi, .wmv, .flv, .f4v, .3g2, .3gp, .mts, m2ts, .mxf, .nut videos # Allow your users to upload .mkv, .mov, .avi, .wmv, .flv, .f4v, .3g2, .3gp, .mts, m2ts, .mxf, .nut videos
allow_additional_extensions: true allow_additional_extensions: true

View File

@ -15,6 +15,7 @@ storage:
avatars: 'test1/avatars/' avatars: 'test1/avatars/'
web_videos: 'test1/web-videos/' web_videos: 'test1/web-videos/'
streaming_playlists: 'test1/streaming-playlists/' streaming_playlists: 'test1/streaming-playlists/'
original_video_files: 'test1/original-video-files/'
redundancy: 'test1/redundancy/' redundancy: 'test1/redundancy/'
logs: 'test1/logs/' logs: 'test1/logs/'
previews: 'test1/previews/' previews: 'test1/previews/'

View File

@ -15,6 +15,7 @@ storage:
avatars: 'test2/avatars/' avatars: 'test2/avatars/'
web_videos: 'test2/web-videos/' web_videos: 'test2/web-videos/'
streaming_playlists: 'test2/streaming-playlists/' streaming_playlists: 'test2/streaming-playlists/'
original_video_files: 'test2/original-video-files/'
redundancy: 'test2/redundancy/' redundancy: 'test2/redundancy/'
logs: 'test2/logs/' logs: 'test2/logs/'
previews: 'test2/previews/' previews: 'test2/previews/'

View File

@ -15,6 +15,7 @@ storage:
avatars: 'test3/avatars/' avatars: 'test3/avatars/'
web_videos: 'test3/web-videos/' web_videos: 'test3/web-videos/'
streaming_playlists: 'test3/streaming-playlists/' streaming_playlists: 'test3/streaming-playlists/'
original_video_files: 'test3/original-video-files/'
redundancy: 'test3/redundancy/' redundancy: 'test3/redundancy/'
logs: 'test3/logs/' logs: 'test3/logs/'
previews: 'test3/previews/' previews: 'test3/previews/'

View File

@ -15,6 +15,7 @@ storage:
avatars: 'test4/avatars/' avatars: 'test4/avatars/'
web_videos: 'test4/web-videos/' web_videos: 'test4/web-videos/'
streaming_playlists: 'test4/streaming-playlists/' streaming_playlists: 'test4/streaming-playlists/'
original_video_files: 'test4/original-video-files/'
redundancy: 'test4/redundancy/' redundancy: 'test4/redundancy/'
logs: 'test4/logs/' logs: 'test4/logs/'
previews: 'test4/previews/' previews: 'test4/previews/'

View File

@ -15,6 +15,7 @@ storage:
avatars: 'test5/avatars/' avatars: 'test5/avatars/'
web_videos: 'test5/web-videos/' web_videos: 'test5/web-videos/'
streaming_playlists: 'test5/streaming-playlists/' streaming_playlists: 'test5/streaming-playlists/'
original_video_files: 'test5/original-video-files/'
redundancy: 'test5/redundancy/' redundancy: 'test5/redundancy/'
logs: 'test5/logs/' logs: 'test5/logs/'
previews: 'test5/previews/' previews: 'test5/previews/'

View File

@ -15,6 +15,7 @@ storage:
avatars: 'test6/avatars/' avatars: 'test6/avatars/'
web_videos: 'test6/web-videos/' web_videos: 'test6/web-videos/'
streaming_playlists: 'test6/streaming-playlists/' streaming_playlists: 'test6/streaming-playlists/'
original_video_files: 'test6/original-video-files/'
redundancy: 'test6/redundancy/' redundancy: 'test6/redundancy/'
logs: 'test6/logs/' logs: 'test6/logs/'
previews: 'test6/previews/' previews: 'test6/previews/'

View File

@ -1,5 +1,6 @@
import { import {
LiveVideoLatencyModeType, LiveVideoLatencyModeType,
VideoFileMetadata,
VideoPrivacyType, VideoPrivacyType,
VideoStateType, VideoStateType,
VideoStreamingPlaylistType_Type VideoStreamingPlaylistType_Type
@ -85,7 +86,17 @@ export interface VideoExportJSON {
}[] }[]
source?: { source?: {
filename: string inputFilename: string
resolution: number
size: number
width: number
height: number
fps: number
metadata: VideoFileMetadata
} }
archiveFiles: { archiveFiles: {

View File

@ -117,6 +117,10 @@ export interface CustomConfig {
transcoding: { transcoding: {
enabled: boolean enabled: boolean
originalFile: {
keep: boolean
}
allowAdditionalExtensions: boolean allowAdditionalExtensions: boolean
allowAudioFiles: boolean allowAudioFiles: boolean

View File

@ -1,4 +1,23 @@
import { VideoFileMetadata } from './file/index.js'
import { VideoConstant } from './video-constant.model.js'
export interface VideoSource { export interface VideoSource {
filename: string inputFilename: string
resolution?: VideoConstant<number>
size?: number // Bytes
width?: number
height?: number
fileDownloadUrl: string
fps?: number
metadata?: VideoFileMetadata
createdAt: string | Date createdAt: string | Date
// TODO: remove, deprecated in 6.1
filename: string
} }

View File

@ -106,6 +106,19 @@ export class ConfigCommand extends AbstractCommand {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
keepSourceFile () {
return this.updateExistingSubConfig({
newConfig: {
transcoding: {
originalFile: {
keep: true
}
}
}
})
}
// ---------------------------------------------------------------------------
enableChannelSync () { enableChannelSync () {
return this.setChannelSyncEnabled(true) return this.setChannelSyncEnabled(true)
} }
@ -234,13 +247,17 @@ export class ConfigCommand extends AbstractCommand {
webVideo?: boolean // default true webVideo?: boolean // default true
hls?: boolean // default true hls?: boolean // default true
with0p?: boolean // default false with0p?: boolean // default false
keepOriginal?: boolean // default false
} = {}) { } = {}) {
const { webVideo = true, hls = true, with0p = false } = options const { webVideo = true, hls = true, with0p = false, keepOriginal = false } = options
return this.updateExistingSubConfig({ return this.updateExistingSubConfig({
newConfig: { newConfig: {
transcoding: { transcoding: {
enabled: true, enabled: true,
originalFile: {
keep: keepOriginal
},
allowAudioFiles: true, allowAudioFiles: true,
allowAdditionalExtensions: true, allowAdditionalExtensions: true,
@ -261,13 +278,17 @@ export class ConfigCommand extends AbstractCommand {
enableMinimumTranscoding (options: { enableMinimumTranscoding (options: {
webVideo?: boolean // default true webVideo?: boolean // default true
hls?: boolean // default true hls?: boolean // default true
keepOriginal?: boolean // default false
} = {}) { } = {}) {
const { webVideo = true, hls = true } = options const { webVideo = true, hls = true, keepOriginal = false } = options
return this.updateExistingSubConfig({ return this.updateExistingSubConfig({
newConfig: { newConfig: {
transcoding: { transcoding: {
enabled: true, enabled: true,
originalFile: {
keep: keepOriginal
},
allowAudioFiles: true, allowAudioFiles: true,
allowAdditionalExtensions: true, allowAdditionalExtensions: true,
@ -560,6 +581,9 @@ export class ConfigCommand extends AbstractCommand {
}, },
transcoding: { transcoding: {
enabled: true, enabled: true,
originalFile: {
keep: false
},
remoteRunners: { remoteRunners: {
enabled: false enabled: false
}, },

View File

@ -1,5 +1,5 @@
import { randomInt } from 'crypto'
import { HttpStatusCode } from '@peertube/peertube-models' import { HttpStatusCode } from '@peertube/peertube-models'
import { randomInt } from 'crypto'
import { makePostBodyRequest } from '../requests/index.js' import { makePostBodyRequest } from '../requests/index.js'
export class ObjectStorageCommand { export class ObjectStorageCommand {
@ -50,6 +50,14 @@ export class ObjectStorageCommand {
web_videos: { web_videos: {
bucket_name: this.getMockWebVideosBucketName() bucket_name: this.getMockWebVideosBucketName()
},
user_exports: {
bucket_name: this.getMockUserExportBucketName()
},
original_video_files: {
bucket_name: this.getMockOriginalFileBucketName()
} }
} }
} }
@ -63,6 +71,14 @@ export class ObjectStorageCommand {
return `http://${this.getMockStreamingPlaylistsBucketName()}.${ObjectStorageCommand.getMockEndpointHost()}/` return `http://${this.getMockStreamingPlaylistsBucketName()}.${ObjectStorageCommand.getMockEndpointHost()}/`
} }
getMockUserExportBaseUrl () {
return `http://${this.getMockUserExportBucketName()}.${ObjectStorageCommand.getMockEndpointHost()}/`
}
getMockOriginalFileBaseUrl () {
return `http://${this.getMockOriginalFileBucketName()}.${ObjectStorageCommand.getMockEndpointHost()}/`
}
async prepareDefaultMockBuckets () { async prepareDefaultMockBuckets () {
await this.createMockBucket(this.getMockStreamingPlaylistsBucketName()) await this.createMockBucket(this.getMockStreamingPlaylistsBucketName())
await this.createMockBucket(this.getMockWebVideosBucketName()) await this.createMockBucket(this.getMockWebVideosBucketName())
@ -100,6 +116,14 @@ export class ObjectStorageCommand {
return this.getMockBucketName(name) return this.getMockBucketName(name)
} }
getMockUserExportBucketName (name = 'user-exports') {
return this.getMockBucketName(name)
}
getMockOriginalFileBucketName (name = 'original-video-files') {
return this.getMockBucketName(name)
}
getMockBucketName (name: string) { getMockBucketName (name: string) {
return `${this.seed}-${name}` return `${this.seed}-${name}`
} }

View File

@ -379,6 +379,7 @@ export class PeerTubeServer {
avatars: this.getDirectoryPath('avatars') + '/', avatars: this.getDirectoryPath('avatars') + '/',
web_videos: this.getDirectoryPath('web-videos') + '/', web_videos: this.getDirectoryPath('web-videos') + '/',
streaming_playlists: this.getDirectoryPath('streaming-playlists') + '/', streaming_playlists: this.getDirectoryPath('streaming-playlists') + '/',
original_video_files: this.getDirectoryPath('original-video-files') + '/',
redundancy: this.getDirectoryPath('redundancy') + '/', redundancy: this.getDirectoryPath('redundancy') + '/',
logs: this.getDirectoryPath('logs') + '/', logs: this.getDirectoryPath('logs') + '/',
previews: this.getDirectoryPath('previews') + '/', previews: this.getDirectoryPath('previews') + '/',

View File

@ -1,10 +1,10 @@
import { exec } from 'child_process'
import { copy, ensureDir, remove } from 'fs-extra/esm'
import { readdir, readFile } from 'fs/promises'
import { basename, join } from 'path'
import { wait } from '@peertube/peertube-core-utils' import { wait } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models' import { HttpStatusCode } from '@peertube/peertube-models'
import { getFileSize, isGithubCI, root } from '@peertube/peertube-node-utils' import { isGithubCI, root } from '@peertube/peertube-node-utils'
import { exec } from 'child_process'
import { copy, ensureDir, remove } from 'fs-extra/esm'
import { readFile, readdir } from 'fs/promises'
import { basename, join } from 'path'
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
export class ServersCommand extends AbstractCommand { export class ServersCommand extends AbstractCommand {
@ -84,6 +84,8 @@ export class ServersCommand extends AbstractCommand {
return files.length return files.length
} }
// ---------------------------------------------------------------------------
buildWebVideoFilePath (fileUrl: string) { buildWebVideoFilePath (fileUrl: string) {
return this.buildDirectory(join('web-videos', basename(fileUrl))) return this.buildDirectory(join('web-videos', basename(fileUrl)))
} }
@ -92,13 +94,9 @@ export class ServersCommand extends AbstractCommand {
return this.buildDirectory(join('streaming-playlists', 'hls', videoUUID, basename(fileUrl))) return this.buildDirectory(join('streaming-playlists', 'hls', videoUUID, basename(fileUrl)))
} }
// ---------------------------------------------------------------------------
getLogContent () { getLogContent () {
return readFile(this.buildDirectory('logs/peertube.log')) return readFile(this.buildDirectory('logs/peertube.log'))
} }
async getServerFileSize (subPath: string) {
const path = this.server.servers.buildDirectory(subPath)
return getFileSize(path)
}
} }

View File

@ -1,7 +1,8 @@
import { HttpStatusCode, ResultList, UserExport, UserExportRequestResult, UserExportState } from '@peertube/peertube-models'
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
import { wait } from '@peertube/peertube-core-utils' import { wait } from '@peertube/peertube-core-utils'
import { unwrapBody } from '../requests/requests.js' import { HttpStatusCode, ResultList, UserExport, UserExportRequestResult, UserExportState } from '@peertube/peertube-models'
import { writeFile } from 'fs/promises'
import { makeRawRequest, unwrapBody } from '../requests/requests.js'
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
export class UserExportsCommand extends AbstractCommand { export class UserExportsCommand extends AbstractCommand {
@ -49,6 +50,22 @@ export class UserExportsCommand extends AbstractCommand {
}) })
} }
async downloadLatestArchive (options: OverrideCommandOptions & {
userId: number
destination: string
}) {
const { data } = await this.list(options)
const res = await makeRawRequest({
url: data[0].privateDownloadUrl,
responseType: 'arraybuffer',
redirects: 1,
expectedStatus: HttpStatusCode.OK_200
})
await writeFile(options.destination, res.body)
}
async deleteAllArchives (options: OverrideCommandOptions & { async deleteAllArchives (options: OverrideCommandOptions & {
userId: number userId: number
}) { }) {

View File

@ -19,244 +19,7 @@ describe('Test config API validators', function () {
let server: PeerTubeServer let server: PeerTubeServer
let userAccessToken: string let userAccessToken: string
const updateParams: CustomConfig = { let updateParams: CustomConfig
instance: {
name: 'PeerTube updated',
shortDescription: 'my short description',
description: 'my super description',
terms: 'my super terms',
codeOfConduct: 'my super coc',
creationReason: 'my super reason',
moderationInformation: 'my super moderation information',
administrator: 'Kuja',
maintenanceLifetime: 'forever',
businessModel: 'my super business model',
hardwareInformation: '2vCore 3GB RAM',
languages: [ 'en', 'es' ],
categories: [ 1, 2 ],
isNSFW: true,
defaultNSFWPolicy: 'blur',
defaultClientRoute: '/videos/recently-added',
customizations: {
javascript: 'alert("coucou")',
css: 'body { background-color: red; }'
}
},
theme: {
default: 'default'
},
services: {
twitter: {
username: '@MySuperUsername'
}
},
client: {
videos: {
miniature: {
preferAuthorDisplayName: false
}
},
menu: {
login: {
redirectOnSingleExternalAuth: false
}
}
},
cache: {
previews: {
size: 2
},
captions: {
size: 3
},
torrents: {
size: 4
},
storyboards: {
size: 5
}
},
signup: {
enabled: false,
limit: 5,
requiresApproval: false,
requiresEmailVerification: false,
minimumAge: 16
},
admin: {
email: 'superadmin1@example.com'
},
contactForm: {
enabled: false
},
user: {
history: {
videos: {
enabled: true
}
},
videoQuota: 5242881,
videoQuotaDaily: 318742,
defaultChannelName: 'Main $1 channel'
},
videoChannels: {
maxPerUser: 20
},
transcoding: {
enabled: true,
remoteRunners: {
enabled: true
},
allowAdditionalExtensions: true,
allowAudioFiles: true,
concurrency: 1,
threads: 1,
profile: 'vod_profile',
resolutions: {
'0p': false,
'144p': false,
'240p': false,
'360p': true,
'480p': true,
'720p': false,
'1080p': false,
'1440p': false,
'2160p': false
},
alwaysTranscodeOriginalResolution: false,
webVideos: {
enabled: true
},
hls: {
enabled: false
}
},
live: {
enabled: true,
allowReplay: false,
latencySetting: {
enabled: false
},
maxDuration: 30,
maxInstanceLives: -1,
maxUserLives: 50,
transcoding: {
enabled: true,
remoteRunners: {
enabled: true
},
threads: 4,
profile: 'live_profile',
resolutions: {
'144p': true,
'240p': true,
'360p': true,
'480p': true,
'720p': true,
'1080p': true,
'1440p': true,
'2160p': true
},
alwaysTranscodeOriginalResolution: false
}
},
videoStudio: {
enabled: true,
remoteRunners: {
enabled: true
}
},
videoFile: {
update: {
enabled: true
}
},
import: {
videos: {
concurrency: 1,
http: {
enabled: false
},
torrent: {
enabled: false
}
},
videoChannelSynchronization: {
enabled: false,
maxPerUser: 10
},
users: {
enabled: false
}
},
export: {
users: {
enabled: false,
maxUserVideoQuota: 40,
exportExpiration: 10
}
},
trending: {
videos: {
algorithms: {
enabled: [ 'hot', 'most-viewed', 'most-liked' ],
default: 'most-viewed'
}
}
},
autoBlacklist: {
videos: {
ofUsers: {
enabled: false
}
}
},
followers: {
instance: {
enabled: false,
manualApproval: true
}
},
followings: {
instance: {
autoFollowBack: {
enabled: true
},
autoFollowIndex: {
enabled: true,
indexUrl: 'https://index.example.com'
}
}
},
broadcastMessage: {
enabled: true,
dismissable: true,
message: 'super message',
level: 'warning'
},
search: {
remoteUri: {
users: true,
anonymous: true
},
searchIndex: {
enabled: true,
url: 'https://search.joinpeertube.org',
disableLocalSearch: true,
isDefaultSearch: true
}
},
storyboards: {
enabled: false
}
}
// --------------------------------------------------------------- // ---------------------------------------------------------------
@ -266,6 +29,7 @@ describe('Test config API validators', function () {
server = await createSingleServer(1) server = await createSingleServer(1)
await setAccessTokensToServers([ server ]) await setAccessTokensToServers([ server ])
updateParams = await server.config.getCustomConfig()
const user = { const user = {
username: 'user1', username: 'user1',

View File

@ -1,8 +1,9 @@
import { HttpStatusCode } from '@peertube/peertube-models' import { HttpStatusCode, VideoSource } from '@peertube/peertube-models'
import { import {
PeerTubeServer,
cleanupTests, cleanupTests,
createSingleServer, createSingleServer,
PeerTubeServer, makeRawRequest,
setAccessTokensToServers, setAccessTokensToServers,
setDefaultVideoChannel, setDefaultVideoChannel,
waitJobs waitJobs
@ -148,6 +149,66 @@ describe('Test video sources API validator', function () {
}) })
}) })
describe('When downloading the source file', function () {
let videoFileToken: string
let videoId: string
let source: VideoSource
let user3: string
let user4: string
before(async function () {
this.timeout(60000)
user3 = await server.users.generateUserAndToken('user3')
user4 = await server.users.generateUserAndToken('user4')
await server.config.enableMinimumTranscoding({ hls: true, keepOriginal: true, webVideo: true })
const { uuid } = await server.videos.quickUpload({ name: 'video', token: user3 })
videoId = uuid
videoFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid, token: user3 })
await waitJobs([ server ])
source = await server.videos.getSource({ id: videoId, token: user3 })
})
it('Should fail with an invalid filename', async function () {
await makeRawRequest({ url: server.url + '/download/original-video-files/hello.mp4', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
})
it('Should fail without header token or video file token', async function () {
await makeRawRequest({ url: source.fileDownloadUrl, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should fail with an invalid header token', async function () {
await makeRawRequest({ url: source.fileDownloadUrl, token: 'toto', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
})
it('Should fail with an invalid video file token', async function () {
await makeRawRequest({ url: source.fileDownloadUrl, query: { videoFileToken: 'toto' }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should fail with header token of another user', async function () {
await makeRawRequest({ url: source.fileDownloadUrl, token: user4, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should fail with video file token of another user', async function () {
const videoFileToken = await server.videoToken.getVideoFileToken({ videoId: uuid, token: user4 })
await makeRawRequest({ url: source.fileDownloadUrl, query: { videoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should succeed with a valid header token', async function () {
await makeRawRequest({ url: source.fileDownloadUrl, token: user3, expectedStatus: HttpStatusCode.OK_200 })
})
it('Should succeed with a valid header token', async function () {
await makeRawRequest({ url: source.fileDownloadUrl, query: { videoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
})
})
after(async function () { after(async function () {
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View File

@ -84,6 +84,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.true expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.true
expect(data.transcoding.webVideos.enabled).to.be.true expect(data.transcoding.webVideos.enabled).to.be.true
expect(data.transcoding.hls.enabled).to.be.true expect(data.transcoding.hls.enabled).to.be.true
expect(data.transcoding.originalFile.keep).to.be.false
expect(data.live.enabled).to.be.false expect(data.live.enabled).to.be.false
expect(data.live.allowReplay).to.be.false expect(data.live.allowReplay).to.be.false
@ -205,6 +206,7 @@ function checkUpdatedConfig (data: CustomConfig) {
expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.false expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.false
expect(data.transcoding.hls.enabled).to.be.false expect(data.transcoding.hls.enabled).to.be.false
expect(data.transcoding.webVideos.enabled).to.be.true expect(data.transcoding.webVideos.enabled).to.be.true
expect(data.transcoding.originalFile.keep).to.be.true
expect(data.live.enabled).to.be.true expect(data.live.enabled).to.be.true
expect(data.live.allowReplay).to.be.true expect(data.live.allowReplay).to.be.true
@ -349,6 +351,9 @@ const newCustomConfig: CustomConfig = {
remoteRunners: { remoteRunners: {
enabled: true enabled: true
}, },
originalFile: {
keep: true
},
allowAdditionalExtensions: true, allowAdditionalExtensions: true,
allowAudioFiles: true, allowAudioFiles: true,
threads: 1, threads: 1,

View File

@ -1,14 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js' import { wait } from '@peertube/peertube-core-utils'
import {
cleanupTests, getRedirectionUrl, makeActivityPubRawRequest,
makeRawRequest,
ObjectStorageCommand,
PeerTubeServer,
waitJobs
} from '@peertube/peertube-server-commands'
import { expect } from 'chai'
import { import {
AccountExportJSON, ActivityPubActor, AccountExportJSON, ActivityPubActor,
ActivityPubOrderedCollection, ActivityPubOrderedCollection,
@ -34,6 +26,15 @@ import {
VideoPlaylistType, VideoPlaylistType,
VideoPrivacy VideoPrivacy
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import {
cleanupTests, getRedirectionUrl, makeActivityPubRawRequest,
makeRawRequest,
ObjectStorageCommand,
PeerTubeServer,
waitJobs
} from '@peertube/peertube-server-commands'
import { expectStartWith } from '@tests/shared/checks.js'
import { import {
checkExportFileExists, checkExportFileExists,
checkFileExistsInZIP, checkFileExistsInZIP,
@ -44,8 +45,8 @@ import {
prepareImportExportTests, prepareImportExportTests,
regenerateExport regenerateExport
} from '@tests/shared/import-export.js' } from '@tests/shared/import-export.js'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
import { wait } from '@peertube/peertube-core-utils' import { expect } from 'chai'
function runTest (withObjectStorage: boolean) { function runTest (withObjectStorage: boolean) {
let server: PeerTubeServer let server: PeerTubeServer
@ -69,10 +70,12 @@ function runTest (withObjectStorage: boolean) {
let noahExportId: number let noahExportId: number
let objectStorage: ObjectStorageCommand
before(async function () { before(async function () {
this.timeout(240000) this.timeout(240000)
const objectStorage = withObjectStorage objectStorage = withObjectStorage
? new ObjectStorageCommand() ? new ObjectStorageCommand()
: undefined; : undefined;
@ -126,6 +129,10 @@ function runTest (withObjectStorage: boolean) {
expect(data[0].size).to.be.greaterThan(0) expect(data[0].size).to.be.greaterThan(0)
expect(data[0].state.id).to.equal(UserExportState.COMPLETED) expect(data[0].state.id).to.equal(UserExportState.COMPLETED)
expect(data[0].state.label).to.equal('Completed') expect(data[0].state.label).to.equal('Completed')
if (objectStorage) {
expectStartWith(await getRedirectionUrl(data[0].privateDownloadUrl), objectStorage.getMockUserExportBaseUrl())
}
} }
await waitJobs([ server ]) await waitJobs([ server ])
@ -526,6 +533,14 @@ function runTest (withObjectStorage: boolean) {
for (const url of urls) { for (const url of urls) {
await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 }) await makeRawRequest({ url, expectedStatus: HttpStatusCode.OK_200 })
} }
expect(publicVideo.source.inputFilename).to.equal('video_short.webm')
expect(publicVideo.source.fps).to.equal(25)
expect(publicVideo.source.height).to.equal(720)
expect(publicVideo.source.width).to.equal(1280)
expect(publicVideo.source.metadata?.streams).to.exist
expect(publicVideo.source.resolution).to.equal(720)
expect(publicVideo.source.size).to.equal(218910)
} }
{ {

View File

@ -1,11 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
import {
cleanupTests, makeRawRequest,
ObjectStorageCommand,
PeerTubeServer, waitJobs
} from '@peertube/peertube-server-commands'
import { import {
HttpStatusCode, HttpStatusCode,
LiveVideoLatencyMode, LiveVideoLatencyMode,
@ -17,14 +11,20 @@ import {
VideoPrivacy, VideoPrivacy,
VideoState VideoState
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { prepareImportExportTests } from '@tests/shared/import-export.js'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import { writeFile } from 'fs/promises' import {
import { join } from 'path' ObjectStorageCommand,
import { expect } from 'chai' PeerTubeServer,
import { testImage, testAvatarSize } from '@tests/shared/checks.js' cleanupTests,
import { completeVideoCheck } from '@tests/shared/videos.js' waitJobs
} from '@peertube/peertube-server-commands'
import { testAvatarSize, testImage } from '@tests/shared/checks.js'
import { prepareImportExportTests } from '@tests/shared/import-export.js'
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js' import { completeCheckHlsPlaylist } from '@tests/shared/streaming-playlists.js'
import { completeVideoCheck } from '@tests/shared/videos.js'
import { expect } from 'chai'
import { join } from 'path'
function runTest (withObjectStorage: boolean) { function runTest (withObjectStorage: boolean) {
let server: PeerTubeServer let server: PeerTubeServer
@ -115,17 +115,8 @@ function runTest (withObjectStorage: boolean) {
await server.userExports.request({ userId: noahId, withVideoFiles: true }) await server.userExports.request({ userId: noahId, withVideoFiles: true })
await server.userExports.waitForCreation({ userId: noahId }) await server.userExports.waitForCreation({ userId: noahId })
const { data } = await server.userExports.list({ userId: noahId })
const res = await makeRawRequest({
url: data[0].privateDownloadUrl,
responseType: 'arraybuffer',
redirects: 1,
expectedStatus: HttpStatusCode.OK_200
})
archivePath = join(server.getDirectoryPath('tmp'), 'archive.zip') archivePath = join(server.getDirectoryPath('tmp'), 'archive.zip')
await writeFile(archivePath, res.body) await server.userExports.downloadLatestArchive({ userId: noahId, destination: archivePath })
}) })
it('Should import an archive with video files', async function () { it('Should import an archive with video files', async function () {
@ -444,6 +435,11 @@ function runTest (withObjectStorage: boolean) {
const source = await remoteServer.videos.getSource({ id: otherVideo.uuid }) const source = await remoteServer.videos.getSource({ id: otherVideo.uuid })
expect(source.filename).to.equal('video_short.webm') expect(source.filename).to.equal('video_short.webm')
expect(source.inputFilename).to.equal('video_short.webm')
expect(source.fileDownloadUrl).to.not.exist
expect(source.metadata?.format).to.exist
expect(source.metadata?.streams).to.be.an('array')
} }
{ {
@ -572,6 +568,57 @@ function runTest (withObjectStorage: boolean) {
} }
}) })
it('Should import original file if included in the export', async function () {
this.timeout(120000)
await server.config.enableMinimumTranscoding({ keepOriginal: true })
await remoteServer.config.keepSourceFile()
const archivePath = join(server.getDirectoryPath('tmp'), 'archive2.zip')
const fixture = 'video_short1.webm'
{
const { token, userId } = await server.users.generate('claire')
await server.videos.quickUpload({ name: 'claire video', token, fixture })
await waitJobs([ server ])
await server.userExports.request({ userId, token, withVideoFiles: true })
await server.userExports.waitForCreation({ userId, token })
await server.userExports.downloadLatestArchive({ userId, token, destination: archivePath })
}
{
const { token, userId } = await remoteServer.users.generate('external_claire')
await remoteServer.userImports.importArchive({ fixture: archivePath, userId, token })
await waitJobs([ remoteServer ])
{
const { data } = await remoteServer.videos.listMyVideos({ token })
expect(data).to.have.lengthOf(1)
const source = await remoteServer.videos.getSource({ id: data[0].id })
expect(source.filename).to.equal(fixture)
expect(source.inputFilename).to.equal(fixture)
expect(source.fileDownloadUrl).to.exist
expect(source.metadata?.format).to.exist
expect(source.metadata?.streams).to.be.an('array')
expect(source.metadata.format['format_name']).to.include('webm')
expect(source.createdAt).to.exist
expect(source.fps).to.equal(25)
expect(source.height).to.equal(720)
expect(source.width).to.equal(1280)
expect(source.resolution.id).to.equal(720)
expect(source.size).to.equal(572456)
}
}
})
after(async function () { after(async function () {
MockSmtpServer.Instance.kill() MockSmtpServer.Instance.kill()

View File

@ -1,24 +1,26 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { getAllFiles } from '@peertube/peertube-core-utils' import { getAllFiles } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models' import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models'
import { expectStartWith } from '@tests/shared/checks.js'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils' import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import { import {
cleanupTests,
createMultipleServers,
doubleFollow,
makeGetRequest,
makeRawRequest,
ObjectStorageCommand, ObjectStorageCommand,
PeerTubeServer, PeerTubeServer,
cleanupTests,
createMultipleServers,
doubleFollow, makeGetRequest,
makeRawRequest,
setAccessTokensToServers, setAccessTokensToServers,
setDefaultAccountAvatar, setDefaultAccountAvatar,
setDefaultVideoChannel, setDefaultVideoChannel,
waitJobs waitJobs
} from '@peertube/peertube-server-commands' } from '@peertube/peertube-server-commands'
import { expectStartWith } from '@tests/shared/checks.js'
import { checkDirectoryIsEmpty } from '@tests/shared/directories.js'
import { FIXTURE_URLS } from '@tests/shared/tests.js'
import { checkSourceFile } from '@tests/shared/videos.js'
import { expect } from 'chai'
describe('Test a video file replacement', function () { describe('Test video source management', function () {
let servers: PeerTubeServer[] = [] let servers: PeerTubeServer[] = []
let replaceDate: Date let replaceDate: Date
@ -36,6 +38,7 @@ describe('Test a video file replacement', function () {
await setDefaultAccountAvatar(servers) await setDefaultAccountAvatar(servers)
await servers[0].config.enableFileUpdate() await servers[0].config.enableFileUpdate()
await servers[0].config.enableMinimumTranscoding()
userToken = await servers[0].users.generateUserAndToken('user1') userToken = await servers[0].users.generateUserAndToken('user1')
@ -44,30 +47,95 @@ describe('Test a video file replacement', function () {
}) })
describe('Getting latest video source', () => { describe('Getting latest video source', () => {
const fixture = 'video_short.webm' const fixture1 = 'video_short.webm'
const fixture2 = 'video_short1.webm'
const uuids: string[] = [] const uuids: string[] = []
it('Should get the source filename with legacy upload', async function () { it('Should get the source filename with legacy upload with disabled keep original file', async function () {
this.timeout(30000) this.timeout(30000)
const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video', fixture }, mode: 'legacy' }) const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video', fixture: fixture1 }, mode: 'legacy' })
uuids.push(uuid) uuids.push(uuid)
await waitJobs(servers)
const source = await servers[0].videos.getSource({ id: uuid }) const source = await servers[0].videos.getSource({ id: uuid })
expect(source.filename).to.equal(fixture) expect(source.filename).to.equal(fixture1)
expect(source.inputFilename).to.equal(fixture1)
expect(source.fileDownloadUrl).to.be.null
expect(source.createdAt).to.exist
expect(source.fps).to.equal(25)
expect(source.height).to.equal(720)
expect(source.width).to.equal(1280)
expect(source.resolution.id).to.equal(720)
expect(source.size).to.equal(218910)
expect(source.metadata?.format).to.exist
expect(source.metadata?.streams).to.be.an('array')
await checkDirectoryIsEmpty(servers[0], 'original-video-files')
}) })
it('Should get the source filename with resumable upload', async function () { it('Should get the source filename with resumable upload and enabled keep original file', async function () {
this.timeout(30000) this.timeout(30000)
const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video', fixture }, mode: 'resumable' }) await servers[0].config.keepSourceFile()
const { uuid } = await servers[0].videos.upload({ attributes: { name: 'my video', fixture: fixture2 }, mode: 'resumable' })
uuids.push(uuid) uuids.push(uuid)
await waitJobs(servers)
const source = await servers[0].videos.getSource({ id: uuid }) const source = await servers[0].videos.getSource({ id: uuid })
expect(source.filename).to.equal(fixture) expect(source.filename).to.equal(fixture2)
expect(source.inputFilename).to.equal(fixture2)
expect(source.fileDownloadUrl).to.exist
expect(source.createdAt).to.exist
expect(source.fps).to.equal(25)
expect(source.height).to.equal(720)
expect(source.width).to.equal(1280)
expect(source.resolution.id).to.equal(720)
expect(source.size).to.equal(572456)
expect(source.metadata?.format).to.exist
expect(source.metadata?.streams).to.be.an('array')
}) })
after(async function () { it('Should have kept original video file', async function () {
await checkSourceFile({ server: servers[0], fsCount: 1, fixture: fixture2, uuid: uuids[uuids.length - 1] })
})
it('Should transcode a file but do not replace original file', async function () {
await servers[0].videos.runTranscoding({ transcodingType: 'web-video', videoId: uuids[0] })
await servers[0].videos.runTranscoding({ transcodingType: 'web-video', videoId: uuids[1] })
await checkSourceFile({ server: servers[0], fsCount: 1, fixture: fixture2, uuid: uuids[uuids.length - 1] })
})
it('Should also keep audio files', async function () {
const fixture = 'sample.ogg'
const { uuid } = await servers[0].videos.quickUpload({ name: 'audio', fixture })
uuids.push(uuid)
await waitJobs(servers)
const source = await checkSourceFile({ server: servers[0], fsCount: 2, fixture, uuid })
expect(source.createdAt).to.exist
expect(source.fps).to.equal(0)
expect(source.height).to.equal(0)
expect(source.width).to.equal(0)
expect(source.resolution.id).to.equal(0)
expect(source.resolution.label).to.equal('Audio')
expect(source.size).to.equal(105243)
expect(source.metadata?.format).to.exist
expect(source.metadata?.streams).to.be.an('array')
})
it('Should delete all videos and do not have original files anymore', async function () {
this.timeout(60000) this.timeout(60000)
for (const uuid of uuids) { for (const uuid of uuids) {
@ -75,6 +143,23 @@ describe('Test a video file replacement', function () {
} }
await waitJobs(servers) await waitJobs(servers)
await checkDirectoryIsEmpty(servers[0], 'original-video-files')
})
it('Should not have source on import', async function () {
const { video: { uuid } } = await servers[0].videoImports.importVideo({
attributes: {
channelId: servers[0].store.channel.id,
targetUrl: FIXTURE_URLS.goodVideo,
privacy: VideoPrivacy.PUBLIC
}
})
await waitJobs(servers)
await servers[0].videos.getSource({ id: uuid, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
await checkDirectoryIsEmpty(servers[0], 'original-video-files')
}) })
}) })
@ -110,18 +195,25 @@ describe('Test a video file replacement', function () {
} }
}) })
it('Should not have kept original video file', async function () {
await checkDirectoryIsEmpty(servers[0], 'original-video-files')
})
it('Should replace a video file with transcoding enabled', async function () { it('Should replace a video file with transcoding enabled', async function () {
this.timeout(240000) this.timeout(240000)
const previousPaths: string[] = [] const previousPaths: string[] = []
await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true, keepOriginal: true })
const { uuid: videoUUID } = await servers[0].videos.quickUpload({ name: 'fs with transcoding', fixture: 'video_short_720p.mp4' }) const uploadFixture = 'video_short_720p.mp4'
const { uuid: videoUUID } = await servers[0].videos.quickUpload({ name: 'fs with transcoding', fixture: uploadFixture })
uuid = videoUUID uuid = videoUUID
await waitJobs(servers) await waitJobs(servers)
await checkSourceFile({ server: servers[0], fsCount: 1, uuid, fixture: uploadFixture })
for (const server of servers) { for (const server of servers) {
const video = await server.videos.get({ id: uuid }) const video = await server.videos.get({ id: uuid })
expect(video.inputFileUpdatedAt).to.be.null expect(video.inputFileUpdatedAt).to.be.null
@ -151,9 +243,23 @@ describe('Test a video file replacement', function () {
replaceDate = new Date() replaceDate = new Date()
await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_360p.mp4' }) const replaceFixture = 'video_short_360p.mp4'
await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: replaceFixture })
await waitJobs(servers) await waitJobs(servers)
const source = await checkSourceFile({ server: servers[0], fsCount: 1, uuid, fixture: replaceFixture })
expect(source.createdAt).to.exist
expect(source.fps).to.equal(25)
expect(source.height).to.equal(360)
expect(source.width).to.equal(640)
expect(source.resolution.id).to.equal(360)
expect(source.resolution.label).to.equal('360p')
expect(source.size).to.equal(30620)
expect(source.metadata?.format).to.exist
expect(source.metadata?.streams).to.be.an('array')
for (const server of servers) { for (const server of servers) {
const video = await server.videos.get({ id: uuid }) const video = await server.videos.get({ id: uuid })
@ -189,35 +295,36 @@ describe('Test a video file replacement', function () {
} }
} }
await servers[0].config.enableMinimumTranscoding() await servers[0].config.enableMinimumTranscoding({ keepOriginal: true })
}) })
it('Should have cleaned up old files', async function () { it('Should have cleaned up old files', async function () {
{ {
const count = await servers[0].servers.countFiles('storyboards') const count = await servers[0].servers.countFiles('storyboards')
expect(count).to.equal(2) expect(count).to.equal(3)
} }
{ {
const count = await servers[0].servers.countFiles('web-videos') const count = await servers[0].servers.countFiles('web-videos')
expect(count).to.equal(5 + 1) // +1 for private directory expect(count).to.equal(6 + 1) // +1 for private directory
} }
{ {
const count = await servers[0].servers.countFiles('streaming-playlists/hls') const count = await servers[0].servers.countFiles('streaming-playlists/hls')
expect(count).to.equal(1 + 1) // +1 for private directory expect(count).to.equal(2 + 1) // +1 for private directory
} }
{ {
const count = await servers[0].servers.countFiles('torrents') const count = await servers[0].servers.countFiles('torrents')
expect(count).to.equal(9) expect(count).to.equal(11)
} }
}) })
it('Should have the correct source input', async function () { it('Should have the correct source input filename', async function () {
const source = await servers[0].videos.getSource({ id: uuid }) const source = await servers[0].videos.getSource({ id: uuid })
expect(source.filename).to.equal('video_short_360p.mp4') expect(source.filename).to.equal('video_short_360p.mp4')
expect(source.inputFilename).to.equal('video_short_360p.mp4')
expect(new Date(source.createdAt)).to.be.above(replaceDate) expect(new Date(source.createdAt)).to.be.above(replaceDate)
}) })
@ -367,6 +474,9 @@ describe('Test a video file replacement', function () {
expect(files[0].resolution.id).to.equal(360) expect(files[0].resolution.id).to.equal(360)
expectStartWith(files[0].fileUrl, objectStorage.getMockWebVideosBaseUrl()) expectStartWith(files[0].fileUrl, objectStorage.getMockWebVideosBaseUrl())
} }
const source = await servers[0].videos.getSource({ id: uuid })
expect(source.fileDownloadUrl).to.not.exist
}) })
it('Should replace a video file with transcoding enabled', async function () { it('Should replace a video file with transcoding enabled', async function () {
@ -374,16 +484,25 @@ describe('Test a video file replacement', function () {
const previousPaths: string[] = [] const previousPaths: string[] = []
await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true, keepOriginal: true })
const fixture1 = 'video_short_360p.mp4'
const { uuid: videoUUID } = await servers[0].videos.quickUpload({ const { uuid: videoUUID } = await servers[0].videos.quickUpload({
name: 'object storage with transcoding', name: 'object storage with transcoding',
fixture: 'video_short_360p.mp4' fixture: fixture1
}) })
uuid = videoUUID uuid = videoUUID
await waitJobs(servers) await waitJobs(servers)
await checkSourceFile({
server: servers[0],
fixture: fixture1,
fsCount: 0,
uuid,
objectStorageBaseUrl: objectStorage?.getMockOriginalFileBaseUrl()
})
for (const server of servers) { for (const server of servers) {
const video = await server.videos.get({ id: uuid }) const video = await server.videos.get({ id: uuid })
@ -403,9 +522,18 @@ describe('Test a video file replacement', function () {
} }
} }
await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: 'video_short_240p.mp4' }) const fixture2 = 'video_short_240p.mp4'
await servers[0].videos.replaceSourceFile({ videoId: uuid, fixture: fixture2 })
await waitJobs(servers) await waitJobs(servers)
await checkSourceFile({
server: servers[0],
fixture: fixture2,
fsCount: 0,
uuid,
objectStorageBaseUrl: objectStorage?.getMockOriginalFileBaseUrl()
})
for (const server of servers) { for (const server of servers) {
const video = await server.videos.get({ id: uuid }) const video = await server.videos.get({ id: uuid })

View File

@ -1,4 +1,5 @@
export * from './client-cli.js' export * from './client-cli.js'
export * from './live-transcoding.js' export * from './live-transcoding.js'
export * from './replace-file.js'
export * from './studio-transcoding.js' export * from './studio-transcoding.js'
export * from './vod-transcoding.js' export * from './vod-transcoding.js'

View File

@ -0,0 +1,86 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { getAllFiles } from '@peertube/peertube-core-utils'
import {
cleanupTests,
createSingleServer,
PeerTubeServer,
setAccessTokensToServers,
setDefaultVideoChannel,
waitJobs
} from '@peertube/peertube-server-commands'
import { PeerTubeRunnerProcess } from '@tests/shared/peertube-runner-process.js'
import { checkSourceFile } from '@tests/shared/videos.js'
import { expect } from 'chai'
describe('Test replace file using peertube-runner program', function () {
let server: PeerTubeServer
let peertubeRunner: PeerTubeRunnerProcess
let uuid: string
before(async function () {
this.timeout(120_000)
server = await createSingleServer(1)
await setAccessTokensToServers([ server ])
await setDefaultVideoChannel([ server ])
await server.config.enableRemoteTranscoding()
await server.config.enableFileUpdate()
await server.config.enableMinimumTranscoding({ hls: true, keepOriginal: true, webVideo: true })
const registrationToken = await server.runnerRegistrationTokens.getFirstRegistrationToken()
peertubeRunner = new PeerTubeRunnerProcess(server)
await peertubeRunner.runServer()
await peertubeRunner.registerPeerTubeInstance({ registrationToken, runnerName: 'runner' })
})
it('Should upload a webm video, transcode it and keep original file', async function () {
this.timeout(240000)
const fixture = 'video_short.webm';
({ uuid } = await server.videos.quickUpload({ name: 'video', fixture }))
await waitJobs(server, { runnerJobs: true })
const video = await server.videos.get({ id: uuid })
const files = getAllFiles(video)
expect(files).to.have.lengthOf(4)
expect(files[0].resolution.id).to.equal(720)
await checkSourceFile({ server, fsCount: 1, fixture, uuid })
})
it('Should upload an audio file, transcode it and keep original file', async function () {
const fixture = 'sample.ogg'
const { uuid } = await server.videos.quickUpload({ name: 'audio', fixture })
await waitJobs([ server ], { runnerJobs: true })
await checkSourceFile({ server, fsCount: 2, fixture, uuid })
})
it('Should replace the video', async function () {
const fixture = 'video_short_360p.mp4'
await server.videos.replaceSourceFile({ videoId: uuid, fixture })
await waitJobs(server, { runnerJobs: true })
const video = await server.videos.get({ id: uuid })
const files = getAllFiles(video)
expect(files).to.have.lengthOf(4)
expect(files[0].resolution.id).to.equal(360)
await checkSourceFile({ server, fsCount: 2, fixture, uuid })
})
after(async function () {
if (peertubeRunner) {
await peertubeRunner.unregisterPeerTubeInstance({ runnerName: 'runner' })
peertubeRunner.kill()
}
await cleanupTests([ server ])
})
})

View File

@ -1,23 +1,23 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
import { uuidRegex } from '@peertube/peertube-core-utils'
import { HttpStatusCode, HttpStatusCodeType, VideoCaption, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
import { buildAbsoluteFixturePath, getFileSize, getFilenameFromUrl, getLowercaseExtension } from '@peertube/peertube-node-utils'
import { PeerTubeServer, VideoEdit, getRedirectionUrl, makeRawRequest, waitJobs } from '@peertube/peertube-server-commands'
import {
VIDEO_CATEGORIES,
VIDEO_LANGUAGES,
VIDEO_LICENCES,
VIDEO_PRIVACIES,
loadLanguages
} from '@peertube/peertube-server/core/initializers/constants.js'
import { expect } from 'chai' import { expect } from 'chai'
import { pathExists } from 'fs-extra/esm' import { pathExists } from 'fs-extra/esm'
import { readdir } from 'fs/promises' import { readdir } from 'fs/promises'
import { basename, join } from 'path' import { basename, join } from 'path'
import { uuidRegex } from '@peertube/peertube-core-utils'
import { HttpStatusCode, HttpStatusCodeType, VideoCaption, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
import {
loadLanguages,
VIDEO_CATEGORIES,
VIDEO_LANGUAGES,
VIDEO_LICENCES,
VIDEO_PRIVACIES
} from '@peertube/peertube-server/core/initializers/constants.js'
import { getLowercaseExtension } from '@peertube/peertube-node-utils'
import { makeRawRequest, PeerTubeServer, VideoEdit, waitJobs } from '@peertube/peertube-server-commands'
import { dateIsValid, expectStartWith, testImageGeneratedByFFmpeg } from './checks.js' import { dateIsValid, expectStartWith, testImageGeneratedByFFmpeg } from './checks.js'
import { checkWebTorrentWorks } from './webtorrent.js'
import { completeCheckHlsPlaylist } from './streaming-playlists.js' import { completeCheckHlsPlaylist } from './streaming-playlists.js'
import { checkWebTorrentWorks } from './webtorrent.js'
export async function completeWebVideoFilesCheck (options: { export async function completeWebVideoFilesCheck (options: {
server: PeerTubeServer server: PeerTubeServer
@ -369,3 +369,40 @@ export async function uploadRandomVideoOnServers (
return res return res
} }
export async function checkSourceFile (options: {
server: PeerTubeServer
fsCount: number
uuid: string
fixture: string
objectStorageBaseUrl?: string // default false
}) {
const { server, fsCount, fixture, uuid, objectStorageBaseUrl } = options
const source = await server.videos.getSource({ id: uuid })
const fixtureFileSize = await getFileSize(buildAbsoluteFixturePath(fixture))
if (fsCount > 0) {
expect(await server.servers.countFiles('original-video-files')).to.equal(fsCount)
const keptFilePath = join(server.servers.buildDirectory('original-video-files'), getFilenameFromUrl(source.fileDownloadUrl))
expect(await getFileSize(keptFilePath)).to.equal(fixtureFileSize)
}
expect(source.fileDownloadUrl).to.exist
if (objectStorageBaseUrl) {
const token = await server.videoToken.getVideoFileToken({ videoId: uuid })
expectStartWith(await getRedirectionUrl(source.fileDownloadUrl + '?videoFileToken=' + token), objectStorageBaseUrl)
}
const { body } = await makeRawRequest({
url: source.fileDownloadUrl,
token: server.accessToken,
redirects: 1,
expectedStatus: HttpStatusCode.OK_200
})
expect(body).to.have.lengthOf(fixtureFileSize)
return source
}

View File

@ -320,6 +320,9 @@ function customConfig (): CustomConfig {
}, },
transcoding: { transcoding: {
enabled: CONFIG.TRANSCODING.ENABLED, enabled: CONFIG.TRANSCODING.ENABLED,
originalFile: {
keep: CONFIG.TRANSCODING.ORIGINAL_FILE.KEEP
},
remoteRunners: { remoteRunners: {
enabled: CONFIG.TRANSCODING.REMOTE_RUNNERS.ENABLED enabled: CONFIG.TRANSCODING.REMOTE_RUNNERS.ENABLED
}, },

View File

@ -184,9 +184,9 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
duration: 0, duration: 0,
state: VideoState.WAITING_FOR_LIVE, state: VideoState.WAITING_FOR_LIVE,
isLive: true, isLive: true,
filename: null inputFilename: null
}, },
videoFilePath: undefined, videoFile: undefined,
user: res.locals.oauth.token.User, user: res.locals.oauth.token.User,
thumbnails thumbnails
}) })

View File

@ -1,20 +1,20 @@
import express from 'express' import { buildAspectRatio } from '@peertube/peertube-core-utils'
import { move } from 'fs-extra/esm' import { VideoState } from '@peertube/peertube-models'
import { sequelizeTypescript } from '@server/initializers/database.js' import { sequelizeTypescript } from '@server/initializers/database.js'
import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue/index.js' import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js' import { Hooks } from '@server/lib/plugins/hooks.js'
import { regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail.js' import { regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail.js'
import { setupUploadResumableRoutes } from '@server/lib/uploadx.js' import { setupUploadResumableRoutes } from '@server/lib/uploadx.js'
import { buildMoveJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js' import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
import { buildNewFile } from '@server/lib/video-file.js' import { buildNewFile, createVideoSource } from '@server/lib/video-file.js'
import { buildMoveJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js' import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { buildNextVideoState } from '@server/lib/video-state.js' import { buildNextVideoState } from '@server/lib/video-state.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js' import { openapiOperationDoc } from '@server/middlewares/doc.js'
import { VideoModel } from '@server/models/video/video.js' import { VideoModel } from '@server/models/video/video.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models/index.js' import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { VideoState } from '@peertube/peertube-models' import express from 'express'
import { move } from 'fs-extra/esm'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js' import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import { import {
asyncMiddleware, asyncMiddleware,
@ -23,7 +23,6 @@ import {
replaceVideoSourceResumableValidator, replaceVideoSourceResumableValidator,
videoSourceGetLatestValidator videoSourceGetLatestValidator
} from '../../../middlewares/index.js' } from '../../../middlewares/index.js'
import { buildAspectRatio } from '@peertube/peertube-core-utils'
const lTags = loggerTagsFactory('api', 'video') const lTags = loggerTagsFactory('api', 'video')
@ -61,7 +60,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R
const videoPhysicalFile = res.locals.updateVideoFileResumable const videoPhysicalFile = res.locals.updateVideoFileResumable
const user = res.locals.oauth.token.User const user = res.locals.oauth.token.User
const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' }) const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video', ffprobe: res.locals.ffprobe })
const originalFilename = videoPhysicalFile.originalname const originalFilename = videoPhysicalFile.originalname
const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(res.locals.videoAll.uuid) const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(res.locals.videoAll.uuid)
@ -114,13 +113,15 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R
await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists }) await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists })
const source = await VideoSourceModel.create({ const source = await createVideoSource({
filename: originalFilename, inputFilename: originalFilename,
videoId: video.id, inputProbe: res.locals.ffprobe,
inputPath: destination,
video,
createdAt: inputFileUpdatedAt createdAt: inputFileUpdatedAt
}) })
await regenerateMiniaturesIfNeeded(video) await regenerateMiniaturesIfNeeded(video, res.locals.ffprobe)
await video.VideoChannel.setAsUpdated() await video.VideoChannel.setAsUpdated()
await addVideoJobsAfterUpload(video, video.getMaxQualityFile()) await addVideoJobsAfterUpload(video, video.getMaxQualityFile())

View File

@ -1,12 +1,14 @@
import express from 'express' import { ffprobePromise, getChaptersFromContainer } from '@peertube/peertube-ffmpeg'
import { HttpStatusCode, ThumbnailType, VideoCreate } from '@peertube/peertube-models'
import { uuidToShort } from '@peertube/peertube-node-utils'
import { getResumableUploadPath } from '@server/helpers/upload.js' import { getResumableUploadPath } from '@server/helpers/upload.js'
import { LocalVideoCreator } from '@server/lib/local-video-creator.js'
import { Redis } from '@server/lib/redis.js' import { Redis } from '@server/lib/redis.js'
import { setupUploadResumableRoutes, uploadx } from '@server/lib/uploadx.js' import { setupUploadResumableRoutes, uploadx } from '@server/lib/uploadx.js'
import { buildNextVideoState } from '@server/lib/video-state.js' import { buildNextVideoState } from '@server/lib/video-state.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js' import { openapiOperationDoc } from '@server/middlewares/doc.js'
import { uuidToShort } from '@peertube/peertube-node-utils' import express from 'express'
import { HttpStatusCode, ThumbnailType, VideoCreate } from '@peertube/peertube-models' import { VideoAuditView, auditLoggerFactory, getAuditIdFromRes } from '../../../helpers/audit-logger.js'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger.js'
import { createReqFiles } from '../../../helpers/express-utils.js' import { createReqFiles } from '../../../helpers/express-utils.js'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js' import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants.js' import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants.js'
@ -19,8 +21,6 @@ import {
videosAddResumableInitValidator, videosAddResumableInitValidator,
videosAddResumableValidator videosAddResumableValidator
} from '../../../middlewares/index.js' } from '../../../middlewares/index.js'
import { ffprobePromise, getChaptersFromContainer } from '@peertube/peertube-ffmpeg'
import { LocalVideoCreator } from '@server/lib/local-video-creator.js'
const lTags = loggerTagsFactory('api', 'video') const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos') const auditLogger = auditLoggerFactory('videos')
@ -134,7 +134,12 @@ async function addVideo (options: {
const localVideoCreator = new LocalVideoCreator({ const localVideoCreator = new LocalVideoCreator({
lTags, lTags,
videoFilePath: videoPhysicalFile.path,
videoFile: {
path: videoPhysicalFile.path,
probe: res.locals.ffprobe
},
user: res.locals.oauth.token.User, user: res.locals.oauth.token.User,
channel: res.locals.videoChannel, channel: res.locals.videoChannel,
@ -148,7 +153,7 @@ async function addVideo (options: {
...videoInfo, ...videoInfo,
duration: videoPhysicalFile.duration, duration: videoPhysicalFile.duration,
filename: videoPhysicalFile.originalname, inputFilename: videoPhysicalFile.originalname,
state: buildNextVideoState(), state: buildNextVideoState(),
isLive: false isLive: false
}, },

View File

@ -1,12 +1,14 @@
import cors from 'cors' import { forceNumber } from '@peertube/peertube-core-utils'
import express from 'express' import { FileStorage, HttpStatusCode, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js' import { logger } from '@server/helpers/logger.js'
import { VideoTorrentsSimpleFileCache } from '@server/lib/files-cache/index.js' import { VideoTorrentsSimpleFileCache } from '@server/lib/files-cache/index.js'
import { import {
generateHLSFilePresignedUrl, generateHLSFilePresignedUrl,
generateOriginalFilePresignedUrl,
generateUserExportPresignedUrl, generateUserExportPresignedUrl,
generateWebVideoPresignedUrl generateWebVideoPresignedUrl
} from '@server/lib/object-storage/index.js' } from '@server/lib/object-storage/index.js'
import { getFSUserExportFilePath } from '@server/lib/paths.js'
import { Hooks } from '@server/lib/plugins/hooks.js' import { Hooks } from '@server/lib/plugins/hooks.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js' import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { import {
@ -17,15 +19,16 @@ import {
MVideoFile, MVideoFile,
MVideoFullLight MVideoFullLight
} from '@server/types/models/index.js' } from '@server/types/models/index.js'
import { forceNumber } from '@peertube/peertube-core-utils' import { MVideoSource } from '@server/types/models/video/video-source.js'
import { HttpStatusCode, FileStorage, VideoStreamingPlaylistType } from '@peertube/peertube-models' import cors from 'cors'
import express from 'express'
import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants.js' import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants.js'
import { import {
asyncMiddleware, optionalAuthenticate, asyncMiddleware, optionalAuthenticate,
originalVideoFileDownloadValidator,
userExportDownloadValidator, userExportDownloadValidator,
videosDownloadValidator videosDownloadValidator
} from '../middlewares/index.js' } from '../middlewares/index.js'
import { getFSUserExportFilePath } from '@server/lib/paths.js'
const downloadRouter = express.Router() const downloadRouter = express.Router()
@ -40,7 +43,7 @@ downloadRouter.use(
STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension', STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension',
optionalAuthenticate, optionalAuthenticate,
asyncMiddleware(videosDownloadValidator), asyncMiddleware(videosDownloadValidator),
asyncMiddleware(downloadVideoFile) asyncMiddleware(downloadWebVideoFile)
) )
downloadRouter.use( downloadRouter.use(
@ -51,11 +54,18 @@ downloadRouter.use(
) )
downloadRouter.use( downloadRouter.use(
STATIC_DOWNLOAD_PATHS.USER_EXPORT + ':filename', STATIC_DOWNLOAD_PATHS.USER_EXPORTS + ':filename',
asyncMiddleware(userExportDownloadValidator), // Include JWT token authentication asyncMiddleware(userExportDownloadValidator), // Include JWT token authentication
asyncMiddleware(downloadUserExport) asyncMiddleware(downloadUserExport)
) )
downloadRouter.use(
STATIC_DOWNLOAD_PATHS.ORIGINAL_VIDEO_FILE + ':filename',
optionalAuthenticate,
asyncMiddleware(originalVideoFileDownloadValidator),
asyncMiddleware(downloadOriginalFile)
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
@ -91,7 +101,7 @@ async function downloadTorrent (req: express.Request, res: express.Response) {
return res.download(result.path, result.downloadName) return res.download(result.path, result.downloadName)
} }
async function downloadVideoFile (req: express.Request, res: express.Response) { async function downloadWebVideoFile (req: express.Request, res: express.Response) {
const video = res.locals.videoAll const video = res.locals.videoAll
const videoFile = getVideoFile(req, video.VideoFiles) const videoFile = getVideoFile(req, video.VideoFiles)
@ -184,6 +194,19 @@ function downloadUserExport (req: express.Request, res: express.Response) {
return Promise.resolve() return Promise.resolve()
} }
function downloadOriginalFile (req: express.Request, res: express.Response) {
const videoSource = res.locals.videoSource
const downloadFilename = videoSource.inputFilename
if (videoSource.storage === FileStorage.OBJECT_STORAGE) {
return redirectOriginalFileToObjectStorage({ res, videoSource, downloadFilename })
}
res.download(VideoPathManager.Instance.getFSOriginalVideoFilePath(videoSource.keptOriginalFilename), downloadFilename)
return Promise.resolve()
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getVideoFile (req: express.Request, files: MVideoFile[]) { function getVideoFile (req: express.Request, files: MVideoFile[]) {
@ -262,3 +285,17 @@ async function redirectUserExportToObjectStorage (options: {
return res.redirect(url) return res.redirect(url)
} }
async function redirectOriginalFileToObjectStorage (options: {
res: express.Response
downloadFilename: string
videoSource: MVideoSource
}) {
const { res, downloadFilename, videoSource } = options
const url = await generateOriginalFilePresignedUrl({ videoSource, downloadFilename })
logger.debug('Generating pre-signed URL %s for original video file %s', url, videoSource.keptOriginalFilename)
return res.redirect(url)
}

View File

@ -31,12 +31,12 @@ const privateWebVideoStaticMiddlewares = CONFIG.STATIC_FILES.PRIVATE_FILES_REQUI
staticRouter.use( staticRouter.use(
[ STATIC_PATHS.PRIVATE_WEB_VIDEOS, STATIC_PATHS.LEGACY_PRIVATE_WEB_VIDEOS ], [ STATIC_PATHS.PRIVATE_WEB_VIDEOS, STATIC_PATHS.LEGACY_PRIVATE_WEB_VIDEOS ],
...privateWebVideoStaticMiddlewares, ...privateWebVideoStaticMiddlewares,
express.static(DIRECTORIES.VIDEOS.PRIVATE, { fallthrough: false }), express.static(DIRECTORIES.WEB_VIDEOS.PRIVATE, { fallthrough: false }),
handleStaticError handleStaticError
) )
staticRouter.use( staticRouter.use(
[ STATIC_PATHS.WEB_VIDEOS, STATIC_PATHS.LEGACY_WEB_VIDEOS ], [ STATIC_PATHS.WEB_VIDEOS, STATIC_PATHS.LEGACY_WEB_VIDEOS ],
express.static(DIRECTORIES.VIDEOS.PUBLIC, { fallthrough: false }), express.static(DIRECTORIES.WEB_VIDEOS.PUBLIC, { fallthrough: false }),
handleStaticError handleStaticError
) )

View File

@ -303,34 +303,68 @@ function checkLiveConfig () {
} }
function checkObjectStorageConfig () { function checkObjectStorageConfig () {
if (CONFIG.OBJECT_STORAGE.ENABLED === true) { if (CONFIG.OBJECT_STORAGE.ENABLED !== true) return
if (!CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BUCKET_NAME) { if (!CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BUCKET_NAME) {
throw new Error('videos_bucket should be set when object storage support is enabled.') throw new Error('videos_bucket should be set when object storage support is enabled.')
}
if (!CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME) {
throw new Error('streaming_playlists_bucket should be set when object storage support is enabled.')
}
// Check web videos and hls videos are not in the same bucket or directory
if (
CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BUCKET_NAME === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME &&
CONFIG.OBJECT_STORAGE.WEB_VIDEOS.PREFIX === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.PREFIX
) {
if (CONFIG.OBJECT_STORAGE.WEB_VIDEOS.PREFIX === '') {
throw new Error('Bucket prefixes should be set when the same bucket is used for both types of video.')
} }
if (!CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME) { throw new Error(
throw new Error('streaming_playlists_bucket should be set when object storage support is enabled.') 'Bucket prefixes should be set to different values when the same bucket is used for both types of video.'
)
}
if (CONFIG.TRANSCODING.ORIGINAL_FILE.KEEP) {
if (!CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES.BUCKET_NAME) {
throw new Error('original_video_files_bucket should be set when object storage support is enabled.')
} }
// Check web videos/hls videos are not in the same bucket or directory as original video files
if ( if (
CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BUCKET_NAME === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME && CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BUCKET_NAME === CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES.BUCKET_NAME &&
CONFIG.OBJECT_STORAGE.WEB_VIDEOS.PREFIX === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.PREFIX CONFIG.OBJECT_STORAGE.WEB_VIDEOS.PREFIX === CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES.PREFIX
) { ) {
if (CONFIG.OBJECT_STORAGE.WEB_VIDEOS.PREFIX === '') { if (CONFIG.OBJECT_STORAGE.WEB_VIDEOS.PREFIX === '') {
throw new Error('Object storage bucket prefixes should be set when the same bucket is used for both types of video.') throw new Error('Bucket prefixes should be set when the same bucket is used for both original and web video files.')
} }
throw new Error( throw new Error(
'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.' 'Bucket prefixes should be set to different values when the same bucket is used for both original and web video files.'
) )
} }
if (CONFIG.OBJECT_STORAGE.MAX_UPLOAD_PART > parseBytes('250MB')) { if (
// eslint-disable-next-line max-len CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME === CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES.BUCKET_NAME &&
logger.warn(`Object storage max upload part seems to have a big value (${CONFIG.OBJECT_STORAGE.MAX_UPLOAD_PART} bytes). Consider using a lower one (like 100MB).`) CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.PREFIX === CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES.PREFIX
) {
if (CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.PREFIX === '') {
throw new Error('Bucket prefixes should be set when the same bucket is used for both original and hls files.')
}
throw new Error(
'Bucket prefixes should be set to different values when the same bucket is used for both original and hls files.'
)
} }
} }
if (CONFIG.OBJECT_STORAGE.MAX_UPLOAD_PART > parseBytes('250MB')) {
// eslint-disable-next-line max-len
logger.warn(`Object storage max upload part seems to have a big value (${CONFIG.OBJECT_STORAGE.MAX_UPLOAD_PART} bytes). Consider using a lower one (like 100MB).`)
}
} }
function checkVideoStudioConfig () { function checkVideoStudioConfig () {

View File

@ -32,8 +32,8 @@ function checkMissedConfig () {
'signup.enabled', 'signup.limit', 'signup.requires_approval', 'signup.requires_email_verification', 'signup.minimum_age', 'signup.enabled', 'signup.limit', 'signup.requires_approval', 'signup.requires_email_verification', 'signup.minimum_age',
'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
'redundancy.videos.strategies', 'redundancy.videos.check_interval', 'redundancy.videos.strategies', 'redundancy.videos.check_interval',
'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions', 'transcoding.web_videos.enabled', 'transcoding.enabled', 'transcoding.original_file.keep', 'transcoding.threads', 'transcoding.allow_additional_extensions',
'transcoding.hls.enabled', 'transcoding.profile', 'transcoding.concurrency', 'transcoding.web_videos.enabled', 'transcoding.hls.enabled', 'transcoding.profile', 'transcoding.concurrency',
'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p', 'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p',
'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p', 'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
'transcoding.resolutions.2160p', 'transcoding.always_transcode_original_resolution', 'transcoding.remote_runners.enabled', 'transcoding.resolutions.2160p', 'transcoding.always_transcode_original_resolution', 'transcoding.remote_runners.enabled',
@ -66,7 +66,8 @@ function checkMissedConfig () {
'object_storage.upload_acl.private', 'object_storage.proxy.proxify_private_files', 'object_storage.credentials.access_key_id', 'object_storage.upload_acl.private', 'object_storage.proxy.proxify_private_files', 'object_storage.credentials.access_key_id',
'object_storage.credentials.secret_access_key', 'object_storage.max_upload_part', 'object_storage.streaming_playlists.bucket_name', 'object_storage.credentials.secret_access_key', 'object_storage.max_upload_part', 'object_storage.streaming_playlists.bucket_name',
'object_storage.streaming_playlists.prefix', 'object_storage.streaming_playlists.base_url', 'object_storage.web_videos.bucket_name', 'object_storage.streaming_playlists.prefix', 'object_storage.streaming_playlists.base_url', 'object_storage.web_videos.bucket_name',
'object_storage.web_videos.prefix', 'object_storage.web_videos.base_url', 'object_storage.web_videos.prefix', 'object_storage.web_videos.base_url', 'object_storage.original_video_files.bucket_name',
'object_storage.original_video_files.prefix', 'object_storage.original_video_files.base_url',
'theme.default', 'theme.default',
'feeds.videos.count', 'feeds.comments.count', 'feeds.videos.count', 'feeds.comments.count',
'geo_ip.enabled', 'geo_ip.country.database_url', 'geo_ip.city.database_url', 'geo_ip.enabled', 'geo_ip.country.database_url', 'geo_ip.city.database_url',

View File

@ -114,6 +114,7 @@ const CONFIG = {
LOG_DIR: buildPath(config.get<string>('storage.logs')), LOG_DIR: buildPath(config.get<string>('storage.logs')),
WEB_VIDEOS_DIR: buildPath(config.get<string>('storage.web_videos')), WEB_VIDEOS_DIR: buildPath(config.get<string>('storage.web_videos')),
STREAMING_PLAYLISTS_DIR: buildPath(config.get<string>('storage.streaming_playlists')), STREAMING_PLAYLISTS_DIR: buildPath(config.get<string>('storage.streaming_playlists')),
ORIGINAL_VIDEO_FILES_DIR: buildPath(config.get<string>('storage.original_video_files')),
REDUNDANCY_DIR: buildPath(config.get<string>('storage.redundancy')), REDUNDANCY_DIR: buildPath(config.get<string>('storage.redundancy')),
THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')), THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')),
STORYBOARDS_DIR: buildPath(config.get<string>('storage.storyboards')), STORYBOARDS_DIR: buildPath(config.get<string>('storage.storyboards')),
@ -159,6 +160,11 @@ const CONFIG = {
BUCKET_NAME: config.get<string>('object_storage.user_exports.bucket_name'), BUCKET_NAME: config.get<string>('object_storage.user_exports.bucket_name'),
PREFIX: config.get<string>('object_storage.user_exports.prefix'), PREFIX: config.get<string>('object_storage.user_exports.prefix'),
BASE_URL: config.get<string>('object_storage.user_exports.base_url') BASE_URL: config.get<string>('object_storage.user_exports.base_url')
},
ORIGINAL_VIDEO_FILES: {
BUCKET_NAME: config.get<string>('object_storage.original_video_files.bucket_name'),
PREFIX: config.get<string>('object_storage.original_video_files.prefix'),
BASE_URL: config.get<string>('object_storage.original_video_files.base_url')
} }
}, },
WEBSERVER: { WEBSERVER: {
@ -412,6 +418,9 @@ const CONFIG = {
}, },
TRANSCODING: { TRANSCODING: {
get ENABLED () { return config.get<boolean>('transcoding.enabled') }, get ENABLED () { return config.get<boolean>('transcoding.enabled') },
ORIGINAL_FILE: {
get KEEP () { return config.get<boolean>('transcoding.original_file.keep') }
},
get ALLOW_ADDITIONAL_EXTENSIONS () { return config.get<boolean>('transcoding.allow_additional_extensions') }, get ALLOW_ADDITIONAL_EXTENSIONS () { return config.get<boolean>('transcoding.allow_additional_extensions') },
get ALLOW_AUDIO_FILES () { return config.get<boolean>('transcoding.allow_audio_files') }, get ALLOW_AUDIO_FILES () { return config.get<boolean>('transcoding.allow_audio_files') },
get THREADS () { return config.get<number>('transcoding.threads') }, get THREADS () { return config.get<number>('transcoding.threads') },

View File

@ -45,7 +45,7 @@ import { cpus } from 'os'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 825 const LAST_MIGRATION_VERSION = 830
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -857,7 +857,8 @@ const STATIC_DOWNLOAD_PATHS = {
TORRENTS: '/download/torrents/', TORRENTS: '/download/torrents/',
VIDEOS: '/download/videos/', VIDEOS: '/download/videos/',
HLS_VIDEOS: '/download/streaming-playlists/hls/videos/', HLS_VIDEOS: '/download/streaming-playlists/hls/videos/',
USER_EXPORT: '/download/user-export/' USER_EXPORTS: '/download/user-exports/',
ORIGINAL_VIDEO_FILE: '/download/original-video-files/'
} }
const LAZY_STATIC_PATHS = { const LAZY_STATIC_PATHS = {
THUMBNAILS: '/lazy-static/thumbnails/', THUMBNAILS: '/lazy-static/thumbnails/',
@ -981,11 +982,13 @@ const DIRECTORIES = {
PRIVATE: join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls', 'private') PRIVATE: join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls', 'private')
}, },
VIDEOS: { WEB_VIDEOS: {
PUBLIC: CONFIG.STORAGE.WEB_VIDEOS_DIR, PUBLIC: CONFIG.STORAGE.WEB_VIDEOS_DIR,
PRIVATE: join(CONFIG.STORAGE.WEB_VIDEOS_DIR, 'private') PRIVATE: join(CONFIG.STORAGE.WEB_VIDEOS_DIR, 'private')
}, },
ORIGINAL_VIDEOS: CONFIG.STORAGE.ORIGINAL_VIDEO_FILES_DIR,
HLS_REDUNDANCY: join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls') HLS_REDUNDANCY: join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
} }

View File

@ -96,8 +96,8 @@ function createDirectoriesIfNotExist () {
tasks.push(ensureDir(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE)) tasks.push(ensureDir(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE))
tasks.push(ensureDir(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC)) tasks.push(ensureDir(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC))
tasks.push(ensureDir(DIRECTORIES.VIDEOS.PUBLIC)) tasks.push(ensureDir(DIRECTORIES.WEB_VIDEOS.PUBLIC))
tasks.push(ensureDir(DIRECTORIES.VIDEOS.PRIVATE)) tasks.push(ensureDir(DIRECTORIES.WEB_VIDEOS.PRIVATE))
// Resumable upload directory // Resumable upload directory
tasks.push(ensureDir(DIRECTORIES.RESUMABLE_UPLOAD)) tasks.push(ensureDir(DIRECTORIES.RESUMABLE_UPLOAD))

View File

@ -0,0 +1,91 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
const { transaction } = utils
{
await utils.queryInterface.addColumn('videoSource', 'keptOriginalFilename', {
type: Sequelize.STRING,
allowNull: true
}, { transaction })
}
{
await utils.queryInterface.addColumn('videoSource', 'storage', {
type: Sequelize.INTEGER,
allowNull: true
}, { transaction })
}
{
await utils.queryInterface.addColumn('videoSource', 'resolution', {
type: Sequelize.INTEGER,
allowNull: true
}, { transaction })
}
{
await utils.queryInterface.addColumn('videoSource', 'width', {
type: Sequelize.INTEGER,
allowNull: true
}, { transaction })
}
{
await utils.queryInterface.addColumn('videoSource', 'height', {
type: Sequelize.INTEGER,
allowNull: true
}, { transaction })
}
{
await utils.queryInterface.addColumn('videoSource', 'fps', {
type: Sequelize.INTEGER,
allowNull: true
}, { transaction })
}
{
await utils.queryInterface.addColumn('videoSource', 'size', {
type: Sequelize.INTEGER,
allowNull: true
}, { transaction })
}
{
await utils.queryInterface.addColumn('videoSource', 'metadata', {
type: Sequelize.JSONB,
allowNull: true
}, { transaction })
}
{
await utils.queryInterface.addColumn('videoSource', 'fileUrl', {
type: Sequelize.STRING,
allowNull: true
}, { transaction })
}
{
await utils.queryInterface.renameColumn('videoSource', 'filename', 'inputFilename', { transaction })
}
{
await utils.queryInterface.addColumn('userExport', 'fileUrl', {
type: Sequelize.STRING,
allowNull: true
}, { transaction })
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
down, up
}

View File

@ -166,7 +166,8 @@ async function saveReplayToExternalVideo (options: {
const thumbnails = await generateLocalVideoMiniature({ const thumbnails = await generateLocalVideoMiniature({
video: replayVideo, video: replayVideo,
videoFile: replayVideo.getMaxQualityFile(), videoFile: replayVideo.getMaxQualityFile(),
types: [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ] types: [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ],
ffprobe: undefined
}) })
for (const thumbnail of thumbnails) { for (const thumbnail of thumbnails) {
@ -238,7 +239,7 @@ async function replaceLiveByReplay (options: {
} }
// Regenerate the thumbnail & preview? // Regenerate the thumbnail & preview?
await regenerateMiniaturesIfNeeded(videoWithFiles) await regenerateMiniaturesIfNeeded(videoWithFiles, undefined)
// We consider this is a new video // We consider this is a new video
await moveToNextState({ video: videoWithFiles, isNewVideo: true }) await moveToNextState({ video: videoWithFiles, isNewVideo: true })

View File

@ -1,5 +1,4 @@
import { buildAspectRatio } from '@peertube/peertube-core-utils' import { buildAspectRatio } from '@peertube/peertube-core-utils'
import { ffprobePromise } from '@peertube/peertube-ffmpeg'
import { import {
LiveVideoCreate, LiveVideoCreate,
LiveVideoLatencyMode, LiveVideoLatencyMode,
@ -18,11 +17,10 @@ import { ScheduleVideoUpdateModel } from '@server/models/video/schedule-video-up
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js' import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
import { VideoLiveModel } from '@server/models/video/video-live.js' import { VideoLiveModel } from '@server/models/video/video-live.js'
import { VideoPasswordModel } from '@server/models/video/video-password.js' import { VideoPasswordModel } from '@server/models/video/video-password.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { VideoModel } from '@server/models/video/video.js' import { VideoModel } from '@server/models/video/video.js'
import { MChannel, MChannelAccountLight, MThumbnail, MUser, MVideoFile, MVideoFullLight } from '@server/types/models/index.js' import { MChannel, MChannelAccountLight, MThumbnail, MUser, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { FilteredModelAttributes } from '@server/types/sequelize.js' import { FilteredModelAttributes } from '@server/types/sequelize.js'
import Ffmpeg from 'fluent-ffmpeg' import { FfprobeData } from 'fluent-ffmpeg'
import { move } from 'fs-extra/esm' import { move } from 'fs-extra/esm'
import { getLocalVideoActivityPubUrl } from './activitypub/url.js' import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
import { federateVideoIfNeeded } from './activitypub/videos/federate.js' import { federateVideoIfNeeded } from './activitypub/videos/federate.js'
@ -30,7 +28,7 @@ import { Hooks } from './plugins/hooks.js'
import { generateLocalVideoMiniature, updateLocalVideoMiniatureFromExisting } from './thumbnail.js' import { generateLocalVideoMiniature, updateLocalVideoMiniatureFromExisting } from './thumbnail.js'
import { autoBlacklistVideoIfNeeded } from './video-blacklist.js' import { autoBlacklistVideoIfNeeded } from './video-blacklist.js'
import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js' import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js'
import { buildNewFile } from './video-file.js' import { buildNewFile, createVideoSource } from './video-file.js'
import { addVideoJobsAfterCreation } from './video-jobs.js' import { addVideoJobsAfterCreation } from './video-jobs.js'
import { VideoPathManager } from './video-path-manager.js' import { VideoPathManager } from './video-path-manager.js'
import { setVideoTags } from './video.js' import { setVideoTags } from './video.js'
@ -39,7 +37,7 @@ type VideoAttributes = Omit<VideoCreate, 'channelId'> & {
duration: number duration: number
isLive: boolean isLive: boolean
state: VideoStateType state: VideoStateType
filename: string inputFilename: string
} }
type LiveAttributes = Pick<LiveVideoCreate, 'permanentLive' | 'latencyMode' | 'saveReplay' | 'replaySettings'> & { type LiveAttributes = Pick<LiveVideoCreate, 'permanentLive' | 'latencyMode' | 'saveReplay' | 'replaySettings'> & {
@ -64,6 +62,8 @@ export class LocalVideoCreator {
private readonly lTags: LoggerTagsFn private readonly lTags: LoggerTagsFn
private readonly videoFilePath: string | undefined private readonly videoFilePath: string | undefined
private readonly videoFileProbe: FfprobeData
private readonly videoAttributes: VideoAttributes private readonly videoAttributes: VideoAttributes
private readonly liveAttributes: LiveAttributes | undefined private readonly liveAttributes: LiveAttributes | undefined
@ -72,12 +72,15 @@ export class LocalVideoCreator {
private video: MVideoFullLight private video: MVideoFullLight
private videoFile: MVideoFile private videoFile: MVideoFile
private ffprobe: Ffmpeg.FfprobeData private videoPath: string
constructor (private readonly options: { constructor (private readonly options: {
lTags: LoggerTagsFn lTags: LoggerTagsFn
videoFilePath: string videoFile: {
path: string
probe: FfprobeData
}
videoAttributes: VideoAttributes videoAttributes: VideoAttributes
liveAttributes: LiveAttributes liveAttributes: LiveAttributes
@ -93,7 +96,8 @@ export class LocalVideoCreator {
finalFallback: ChaptersOption | undefined finalFallback: ChaptersOption | undefined
} }
}) { }) {
this.videoFilePath = options.videoFilePath this.videoFilePath = options.videoFile?.path
this.videoFileProbe = options.videoFile?.probe
this.videoAttributes = options.videoAttributes this.videoAttributes = options.videoAttributes
this.liveAttributes = options.liveAttributes this.liveAttributes = options.liveAttributes
@ -112,11 +116,10 @@ export class LocalVideoCreator {
this.video.url = getLocalVideoActivityPubUrl(this.video) this.video.url = getLocalVideoActivityPubUrl(this.video)
if (this.videoFilePath) { if (this.videoFilePath) {
this.ffprobe = await ffprobePromise(this.videoFilePath) this.videoFile = await buildNewFile({ path: this.videoFilePath, mode: 'web-video', ffprobe: this.videoFileProbe })
this.videoFile = await buildNewFile({ path: this.videoFilePath, mode: 'web-video', ffprobe: this.ffprobe })
const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(this.video, this.videoFile) this.videoPath = VideoPathManager.Instance.getFSVideoFileOutputPath(this.video, this.videoFile)
await move(this.videoFilePath, destination) await move(this.videoFilePath, this.videoPath)
this.video.aspectRatio = buildAspectRatio({ width: this.videoFile.width, height: this.videoFile.height }) this.video.aspectRatio = buildAspectRatio({ width: this.videoFile.width, height: this.videoFile.height })
} }
@ -166,13 +169,6 @@ export class LocalVideoCreator {
transaction transaction
}) })
if (this.videoAttributes.filename) {
await VideoSourceModel.create({
filename: this.videoAttributes.filename,
videoId: this.video.id
}, { transaction })
}
if (this.videoAttributes.privacy === VideoPrivacy.PASSWORD_PROTECTED) { if (this.videoAttributes.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
await VideoPasswordModel.addPasswords(this.videoAttributes.videoPasswords, this.video.id, transaction) await VideoPasswordModel.addPasswords(this.videoAttributes.videoPasswords, this.video.id, transaction)
} }
@ -197,10 +193,11 @@ export class LocalVideoCreator {
videoLive.videoId = this.video.id videoLive.videoId = this.video.id
this.video.VideoLive = await videoLive.save({ transaction }) this.video.VideoLive = await videoLive.save({ transaction })
} }
if (this.videoFile) { if (this.videoFile) {
transaction.afterCommit(() => { transaction.afterCommit(() => {
addVideoJobsAfterCreation({ video: this.video, videoFile: this.videoFile }) addVideoJobsAfterCreation({ video: this.video, videoFile: this.videoFile })
.catch(err => logger.error('Cannot build new video jobs of %s.', this.video.uuid, { err, ...this.lTags(this.video.uuid) })) .catch(err => logger.error('Cannot build new video jobs of %s.', this.video.uuid, { err, ...this.lTags(this.video.uuid) }))
}) })
} else { } else {
await federateVideoIfNeeded(this.video, true, transaction) await federateVideoIfNeeded(this.video, true, transaction)
@ -218,6 +215,15 @@ export class LocalVideoCreator {
}) })
}) })
if (this.videoAttributes.inputFilename) {
await createVideoSource({
inputFilename: this.videoAttributes.inputFilename,
inputPath: this.videoPath,
inputProbe: this.videoFileProbe,
video: this.video
})
}
// Channel has a new content, set as updated // Channel has a new content, set as updated
await this.channel.setAsUpdated() await this.channel.setAsUpdated()
@ -248,7 +254,12 @@ export class LocalVideoCreator {
return [ return [
...await Promise.all(promises), ...await Promise.all(promises),
...await generateLocalVideoMiniature({ video: this.video, videoFile: this.videoFile, types: toGenerate, ffprobe: this.ffprobe }) ...await generateLocalVideoMiniature({
video: this.video,
videoFile: this.videoFile,
types: toGenerate,
ffprobe: this.videoFileProbe
})
] ]
} }

View File

@ -1,25 +1,22 @@
import { join } from 'path' import { join } from 'path'
import { MStreamingPlaylistVideo } from '@server/types/models/index.js' import { MStreamingPlaylistVideo } from '@server/types/models/index.js'
function generateHLSObjectStorageKey (playlist: MStreamingPlaylistVideo, filename: string) { export function generateHLSObjectStorageKey (playlist: MStreamingPlaylistVideo, filename: string) {
return join(generateHLSObjectBaseStorageKey(playlist), filename) return join(generateHLSObjectBaseStorageKey(playlist), filename)
} }
function generateHLSObjectBaseStorageKey (playlist: MStreamingPlaylistVideo) { export function generateHLSObjectBaseStorageKey (playlist: MStreamingPlaylistVideo) {
return join(playlist.getStringType(), playlist.Video.uuid) return join(playlist.getStringType(), playlist.Video.uuid)
} }
function generateWebVideoObjectStorageKey (filename: string) { export function generateWebVideoObjectStorageKey (filename: string) {
return filename return filename
} }
function generateUserExportObjectStorageKey (filename: string) { export function generateOriginalVideoObjectStorageKey (filename: string) {
return filename return filename
} }
export { export function generateUserExportObjectStorageKey (filename: string) {
generateHLSObjectStorageKey, return filename
generateHLSObjectBaseStorageKey,
generateWebVideoObjectStorageKey,
generateUserExportObjectStorageKey
} }

View File

@ -1,8 +1,14 @@
import { CONFIG } from '@server/initializers/config.js' import { CONFIG } from '@server/initializers/config.js'
import { MStreamingPlaylistVideo, MUserExport, MVideoFile } from '@server/types/models/index.js' import { MStreamingPlaylistVideo, MUserExport, MVideoFile } from '@server/types/models/index.js'
import { generateHLSObjectStorageKey, generateUserExportObjectStorageKey, generateWebVideoObjectStorageKey } from './keys.js' import { MVideoSource } from '@server/types/models/video/video-source.js'
import {
generateHLSObjectStorageKey,
generateOriginalVideoObjectStorageKey,
generateUserExportObjectStorageKey,
generateWebVideoObjectStorageKey
} from './keys.js'
import { buildKey, getClient } from './shared/index.js' import { buildKey, getClient } from './shared/index.js'
import { getHLSPublicFileUrl, getWebVideoPublicFileUrl } from './urls.js' import { getObjectStoragePublicFileUrl } from './urls.js'
export async function generateWebVideoPresignedUrl (options: { export async function generateWebVideoPresignedUrl (options: {
file: MVideoFile file: MVideoFile
@ -16,7 +22,7 @@ export async function generateWebVideoPresignedUrl (options: {
downloadFilename downloadFilename
}) })
return getWebVideoPublicFileUrl(url) return getObjectStoragePublicFileUrl(url, CONFIG.OBJECT_STORAGE.WEB_VIDEOS)
} }
export async function generateHLSFilePresignedUrl (options: { export async function generateHLSFilePresignedUrl (options: {
@ -32,7 +38,7 @@ export async function generateHLSFilePresignedUrl (options: {
downloadFilename downloadFilename
}) })
return getHLSPublicFileUrl(url) return getObjectStoragePublicFileUrl(url, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
} }
export async function generateUserExportPresignedUrl (options: { export async function generateUserExportPresignedUrl (options: {
@ -47,7 +53,22 @@ export async function generateUserExportPresignedUrl (options: {
downloadFilename downloadFilename
}) })
return getHLSPublicFileUrl(url) return getObjectStoragePublicFileUrl(url, CONFIG.OBJECT_STORAGE.USER_EXPORTS)
}
export async function generateOriginalFilePresignedUrl (options: {
videoSource: MVideoSource
downloadFilename: string
}) {
const { videoSource, downloadFilename } = options
const url = await generatePresignedUrl({
bucket: CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES.BUCKET_NAME,
key: buildKey(generateOriginalVideoObjectStorageKey(videoSource.keptOriginalFilename), CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES),
downloadFilename
})
return getObjectStoragePublicFileUrl(url, CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,23 +1,15 @@
import { CONFIG } from '@server/initializers/config.js'
import { OBJECT_STORAGE_PROXY_PATHS, WEBSERVER } from '@server/initializers/constants.js' import { OBJECT_STORAGE_PROXY_PATHS, WEBSERVER } from '@server/initializers/constants.js'
import { MVideoUUID } from '@server/types/models/index.js' import { MVideoUUID } from '@server/types/models/index.js'
import { BucketInfo, buildKey, getEndpointParsed } from './shared/index.js' import { BucketInfo, buildKey, getEndpointParsed } from './shared/index.js'
function getInternalUrl (config: BucketInfo, keyWithoutPrefix: string) { export function getInternalUrl (config: BucketInfo, keyWithoutPrefix: string) {
return getBaseUrl(config) + buildKey(keyWithoutPrefix, config) return getBaseUrl(config) + buildKey(keyWithoutPrefix, config)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getWebVideoPublicFileUrl (fileUrl: string) { export function getObjectStoragePublicFileUrl (fileUrl: string, objectStorageConfig: { BASE_URL: string }) {
const baseUrl = CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BASE_URL const baseUrl = objectStorageConfig.BASE_URL
if (!baseUrl) return fileUrl
return replaceByBaseUrl(fileUrl, baseUrl)
}
function getHLSPublicFileUrl (fileUrl: string) {
const baseUrl = CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BASE_URL
if (!baseUrl) return fileUrl if (!baseUrl) return fileUrl
return replaceByBaseUrl(fileUrl, baseUrl) return replaceByBaseUrl(fileUrl, baseUrl)
@ -25,28 +17,13 @@ function getHLSPublicFileUrl (fileUrl: string) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getHLSPrivateFileUrl (video: MVideoUUID, filename: string) { export function getHLSPrivateFileUrl (video: MVideoUUID, filename: string) {
return WEBSERVER.URL + OBJECT_STORAGE_PROXY_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + video.uuid + `/${filename}` return WEBSERVER.URL + OBJECT_STORAGE_PROXY_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + video.uuid + `/${filename}`
} }
function getWebVideoPrivateFileUrl (filename: string) { export function getWebVideoPrivateFileUrl (filename: string) {
return WEBSERVER.URL + OBJECT_STORAGE_PROXY_PATHS.PRIVATE_WEB_VIDEOS + filename return WEBSERVER.URL + OBJECT_STORAGE_PROXY_PATHS.PRIVATE_WEB_VIDEOS + filename
} }
// ---------------------------------------------------------------------------
export {
getInternalUrl,
getWebVideoPublicFileUrl,
getHLSPublicFileUrl,
getHLSPrivateFileUrl,
getWebVideoPrivateFileUrl,
replaceByBaseUrl
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getBaseUrl (bucketInfo: BucketInfo, baseUrl?: string) { function getBaseUrl (bucketInfo: BucketInfo, baseUrl?: string) {

View File

@ -1,14 +1,20 @@
import { basename, join } from 'path'
import { logger } from '@server/helpers/logger.js' import { logger } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js' import { CONFIG } from '@server/initializers/config.js'
import { MStreamingPlaylistVideo, MVideo, MVideoFile } from '@server/types/models/index.js' import { MStreamingPlaylistVideo, MVideo, MVideoFile } from '@server/types/models/index.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
import { basename, join } from 'path'
import { getHLSDirectory } from '../paths.js' import { getHLSDirectory } from '../paths.js'
import { VideoPathManager } from '../video-path-manager.js' import { VideoPathManager } from '../video-path-manager.js'
import { generateHLSObjectBaseStorageKey, generateHLSObjectStorageKey, generateWebVideoObjectStorageKey } from './keys.js' import {
generateHLSObjectBaseStorageKey,
generateHLSObjectStorageKey,
generateOriginalVideoObjectStorageKey,
generateWebVideoObjectStorageKey
} from './keys.js'
import { import {
createObjectReadStream, createObjectReadStream,
listKeysOfPrefix,
lTags, lTags,
listKeysOfPrefix,
makeAvailable, makeAvailable,
removeObject, removeObject,
removeObjectByFullKey, removeObjectByFullKey,
@ -19,13 +25,13 @@ import {
updatePrefixACL updatePrefixACL
} from './shared/index.js' } from './shared/index.js'
function listHLSFileKeysOf (playlist: MStreamingPlaylistVideo) { export function listHLSFileKeysOf (playlist: MStreamingPlaylistVideo) {
return listKeysOfPrefix(generateHLSObjectBaseStorageKey(playlist), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS) return listKeysOfPrefix(generateHLSObjectBaseStorageKey(playlist), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function storeHLSFileFromFilename (playlist: MStreamingPlaylistVideo, filename: string) { export function storeHLSFileFromFilename (playlist: MStreamingPlaylistVideo, filename: string) {
return storeObject({ return storeObject({
inputPath: join(getHLSDirectory(playlist.Video), filename), inputPath: join(getHLSDirectory(playlist.Video), filename),
objectStorageKey: generateHLSObjectStorageKey(playlist, filename), objectStorageKey: generateHLSObjectStorageKey(playlist, filename),
@ -34,7 +40,7 @@ function storeHLSFileFromFilename (playlist: MStreamingPlaylistVideo, filename:
}) })
} }
function storeHLSFileFromPath (playlist: MStreamingPlaylistVideo, path: string) { export function storeHLSFileFromPath (playlist: MStreamingPlaylistVideo, path: string) {
return storeObject({ return storeObject({
inputPath: path, inputPath: path,
objectStorageKey: generateHLSObjectStorageKey(playlist, basename(path)), objectStorageKey: generateHLSObjectStorageKey(playlist, basename(path)),
@ -43,7 +49,7 @@ function storeHLSFileFromPath (playlist: MStreamingPlaylistVideo, path: string)
}) })
} }
function storeHLSFileFromContent (playlist: MStreamingPlaylistVideo, path: string, content: string) { export function storeHLSFileFromContent (playlist: MStreamingPlaylistVideo, path: string, content: string) {
return storeContent({ return storeContent({
content, content,
inputPath: path, inputPath: path,
@ -55,7 +61,7 @@ function storeHLSFileFromContent (playlist: MStreamingPlaylistVideo, path: strin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function storeWebVideoFile (video: MVideo, file: MVideoFile) { export function storeWebVideoFile (video: MVideo, file: MVideoFile) {
return storeObject({ return storeObject({
inputPath: VideoPathManager.Instance.getFSVideoFileOutputPath(video, file), inputPath: VideoPathManager.Instance.getFSVideoFileOutputPath(video, file),
objectStorageKey: generateWebVideoObjectStorageKey(file.filename), objectStorageKey: generateWebVideoObjectStorageKey(file.filename),
@ -66,7 +72,18 @@ function storeWebVideoFile (video: MVideo, file: MVideoFile) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function updateWebVideoFileACL (video: MVideo, file: MVideoFile) { export function storeOriginalVideoFile (inputPath: string, filename: string) {
return storeObject({
inputPath,
objectStorageKey: generateOriginalVideoObjectStorageKey(filename),
bucketInfo: CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES,
isPrivate: true
})
}
// ---------------------------------------------------------------------------
export async function updateWebVideoFileACL (video: MVideo, file: MVideoFile) {
await updateObjectACL({ await updateObjectACL({
objectStorageKey: generateWebVideoObjectStorageKey(file.filename), objectStorageKey: generateWebVideoObjectStorageKey(file.filename),
bucketInfo: CONFIG.OBJECT_STORAGE.WEB_VIDEOS, bucketInfo: CONFIG.OBJECT_STORAGE.WEB_VIDEOS,
@ -74,7 +91,7 @@ async function updateWebVideoFileACL (video: MVideo, file: MVideoFile) {
}) })
} }
async function updateHLSFilesACL (playlist: MStreamingPlaylistVideo) { export async function updateHLSFilesACL (playlist: MStreamingPlaylistVideo) {
await updatePrefixACL({ await updatePrefixACL({
prefix: generateHLSObjectBaseStorageKey(playlist), prefix: generateHLSObjectBaseStorageKey(playlist),
bucketInfo: CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS, bucketInfo: CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS,
@ -84,31 +101,37 @@ async function updateHLSFilesACL (playlist: MStreamingPlaylistVideo) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function removeHLSObjectStorage (playlist: MStreamingPlaylistVideo) { export function removeHLSObjectStorage (playlist: MStreamingPlaylistVideo) {
return removePrefix(generateHLSObjectBaseStorageKey(playlist), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS) return removePrefix(generateHLSObjectBaseStorageKey(playlist), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
} }
function removeHLSFileObjectStorageByFilename (playlist: MStreamingPlaylistVideo, filename: string) { export function removeHLSFileObjectStorageByFilename (playlist: MStreamingPlaylistVideo, filename: string) {
return removeObject(generateHLSObjectStorageKey(playlist, filename), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS) return removeObject(generateHLSObjectStorageKey(playlist, filename), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
} }
function removeHLSFileObjectStorageByPath (playlist: MStreamingPlaylistVideo, path: string) { export function removeHLSFileObjectStorageByPath (playlist: MStreamingPlaylistVideo, path: string) {
return removeObject(generateHLSObjectStorageKey(playlist, basename(path)), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS) return removeObject(generateHLSObjectStorageKey(playlist, basename(path)), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
} }
function removeHLSFileObjectStorageByFullKey (key: string) { export function removeHLSFileObjectStorageByFullKey (key: string) {
return removeObjectByFullKey(key, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS) return removeObjectByFullKey(key, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function removeWebVideoObjectStorage (videoFile: MVideoFile) { export function removeWebVideoObjectStorage (videoFile: MVideoFile) {
return removeObject(generateWebVideoObjectStorageKey(videoFile.filename), CONFIG.OBJECT_STORAGE.WEB_VIDEOS) return removeObject(generateWebVideoObjectStorageKey(videoFile.filename), CONFIG.OBJECT_STORAGE.WEB_VIDEOS)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function makeHLSFileAvailable (playlist: MStreamingPlaylistVideo, filename: string, destination: string) { export function removeOriginalFileObjectStorage (videoSource: MVideoSource) {
return removeObject(generateOriginalVideoObjectStorageKey(videoSource.keptOriginalFilename), CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES)
}
// ---------------------------------------------------------------------------
export async function makeHLSFileAvailable (playlist: MStreamingPlaylistVideo, filename: string, destination: string) {
const key = generateHLSObjectStorageKey(playlist, filename) const key = generateHLSObjectStorageKey(playlist, filename)
logger.info('Fetching HLS file %s from object storage to %s.', key, destination, lTags()) logger.info('Fetching HLS file %s from object storage to %s.', key, destination, lTags())
@ -122,7 +145,7 @@ async function makeHLSFileAvailable (playlist: MStreamingPlaylistVideo, filename
return destination return destination
} }
async function makeWebVideoFileAvailable (filename: string, destination: string) { export async function makeWebVideoFileAvailable (filename: string, destination: string) {
const key = generateWebVideoObjectStorageKey(filename) const key = generateWebVideoObjectStorageKey(filename)
logger.info('Fetching Web Video file %s from object storage to %s.', key, destination, lTags()) logger.info('Fetching Web Video file %s from object storage to %s.', key, destination, lTags())
@ -138,7 +161,7 @@ async function makeWebVideoFileAvailable (filename: string, destination: string)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getWebVideoFileReadStream (options: { export function getWebVideoFileReadStream (options: {
filename: string filename: string
rangeHeader: string rangeHeader: string
}) { }) {
@ -153,7 +176,7 @@ function getWebVideoFileReadStream (options: {
}) })
} }
function getHLSFileReadStream (options: { export function getHLSFileReadStream (options: {
playlist: MStreamingPlaylistVideo playlist: MStreamingPlaylistVideo
filename: string filename: string
rangeHeader: string rangeHeader: string
@ -169,29 +192,17 @@ function getHLSFileReadStream (options: {
}) })
} }
// --------------------------------------------------------------------------- export function getOriginalFileReadStream (options: {
keptOriginalFilename: string
rangeHeader: string
}) {
const { keptOriginalFilename, rangeHeader } = options
export { const key = generateOriginalVideoObjectStorageKey(keptOriginalFilename)
listHLSFileKeysOf,
storeWebVideoFile, return createObjectReadStream({
storeHLSFileFromFilename, key,
storeHLSFileFromPath, bucketInfo: CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES,
storeHLSFileFromContent, rangeHeader
})
updateWebVideoFileACL,
updateHLSFilesACL,
removeHLSObjectStorage,
removeHLSFileObjectStorageByFilename,
removeHLSFileObjectStorageByPath,
removeHLSFileObjectStorageByFullKey,
removeWebVideoObjectStorage,
makeWebVideoFileAvailable,
makeHLSFileAvailable,
getWebVideoFileReadStream,
getHLSFileReadStream
} }

View File

@ -100,7 +100,7 @@ function generateLocalVideoMiniature (options: {
video: MVideoThumbnail video: MVideoThumbnail
videoFile: MVideoFile videoFile: MVideoFile
types: ThumbnailType_Type[] types: ThumbnailType_Type[]
ffprobe?: FfprobeData ffprobe: FfprobeData
}): Promise<MThumbnail[]> { }): Promise<MThumbnail[]> {
const { video, videoFile, types, ffprobe } = options const { video, videoFile, types, ffprobe } = options
@ -223,7 +223,7 @@ function updateRemoteVideoThumbnail (options: {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles) { async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles, ffprobe: FfprobeData) {
const thumbnailsToGenerate: ThumbnailType_Type[] = [] const thumbnailsToGenerate: ThumbnailType_Type[] = []
if (video.getMiniature().automaticallyGenerated === true) { if (video.getMiniature().automaticallyGenerated === true) {
@ -237,6 +237,7 @@ async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles) {
const models = await generateLocalVideoMiniature({ const models = await generateLocalVideoMiniature({
video, video,
videoFile: video.getMaxQualityFile(), videoFile: video.getMaxQualityFile(),
ffprobe,
types: thumbnailsToGenerate types: thumbnailsToGenerate
}) })

View File

@ -1,3 +1,4 @@
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@peertube/peertube-ffmpeg'
import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js' import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js' import { CONFIG } from '@server/initializers/config.js'
@ -11,7 +12,6 @@ import {
import { VideoPathManager } from '@server/lib/video-path-manager.js' import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models/index.js' import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models/index.js'
import { MRunnerJob } from '@server/types/models/runners/index.js' import { MRunnerJob } from '@server/types/models/runners/index.js'
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@peertube/peertube-ffmpeg'
import { getTranscodingJobPriority } from '../../transcoding-priority.js' import { getTranscodingJobPriority } from '../../transcoding-priority.js'
import { buildOriginalFileResolution, computeResolutionsToTranscode } from '../../transcoding-resolutions.js' import { buildOriginalFileResolution, computeResolutionsToTranscode } from '../../transcoding-resolutions.js'
import { AbstractJobBuilder } from './abstract-job-builder.js' import { AbstractJobBuilder } from './abstract-job-builder.js'
@ -60,11 +60,7 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
const fps = computeOutputFPS({ inputFPS, resolution: maxResolution }) const fps = computeOutputFPS({ inputFPS, resolution: maxResolution })
const priority = await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 }) const priority = await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
const deleteInputFileId = isAudioInput || maxResolution !== resolution const jobPayload = { video, resolution: maxResolution, fps, isNewVideo, priority, deleteInputFileId: videoFile.id }
? videoFile.id
: null
const jobPayload = { video, resolution: maxResolution, fps, isNewVideo, priority, deleteInputFileId }
const mainRunnerJob = videoFile.isAudio() const mainRunnerJob = videoFile.isAudio()
? await new VODAudioMergeTranscodingJobHandler().create(jobPayload) ? await new VODAudioMergeTranscodingJobHandler().create(jobPayload)

View File

@ -1,22 +1,22 @@
import { Job } from 'bullmq' import { buildAspectRatio } from '@peertube/peertube-core-utils'
import { move, remove } from 'fs-extra/esm' import { TranscodeVODOptionsType, getVideoStreamDuration } from '@peertube/peertube-ffmpeg'
import { copyFile } from 'fs/promises'
import { basename, join } from 'path'
import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js' import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent.js' import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent.js'
import { VideoModel } from '@server/models/video/video.js' import { VideoModel } from '@server/models/video/video.js'
import { MVideoFile, MVideoFullLight } from '@server/types/models/index.js' import { MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { getVideoStreamDuration, TranscodeVODOptionsType } from '@peertube/peertube-ffmpeg' import { Job } from 'bullmq'
import { move, remove } from 'fs-extra/esm'
import { copyFile } from 'fs/promises'
import { basename, join } from 'path'
import { CONFIG } from '../../initializers/config.js' import { CONFIG } from '../../initializers/config.js'
import { VideoFileModel } from '../../models/video/video-file.js' import { VideoFileModel } from '../../models/video/video-file.js'
import { JobQueue } from '../job-queue/index.js' import { JobQueue } from '../job-queue/index.js'
import { generateWebVideoFilename } from '../paths.js' import { generateWebVideoFilename } from '../paths.js'
import { buildNewFile } from '../video-file.js' import { buildNewFile, saveNewOriginalFileIfNeeded } from '../video-file.js'
import { buildStoryboardJobIfNeeded } from '../video-jobs.js'
import { VideoPathManager } from '../video-path-manager.js' import { VideoPathManager } from '../video-path-manager.js'
import { buildFFmpegVOD } from './shared/index.js' import { buildFFmpegVOD } from './shared/index.js'
import { buildOriginalFileResolution } from './transcoding-resolutions.js' import { buildOriginalFileResolution } from './transcoding-resolutions.js'
import { buildStoryboardJobIfNeeded } from '../video-jobs.js'
import { buildAspectRatio } from '@peertube/peertube-core-utils'
// Optimize the original video file and replace it. The resolution is not changed. // Optimize the original video file and replace it. The resolution is not changed.
export async function optimizeOriginalVideofile (options: { export async function optimizeOriginalVideofile (options: {
@ -73,7 +73,7 @@ export async function optimizeOriginalVideofile (options: {
} }
} }
// Transcode the original video file to a lower resolution compatible with web browsers // Transcode the original/old/source video file to a lower resolution compatible with web browsers
export async function transcodeNewWebVideoResolution (options: { export async function transcodeNewWebVideoResolution (options: {
video: MVideoFullLight video: MVideoFullLight
resolution: number resolution: number
@ -162,7 +162,6 @@ export async function mergeAudioVideofile (options: {
try { try {
await buildFFmpegVOD(job).transcode(transcodeOptions) await buildFFmpegVOD(job).transcode(transcodeOptions)
await remove(audioInputPath)
await remove(tmpPreviewPath) await remove(tmpPreviewPath)
} catch (err) { } catch (err) {
await remove(tmpPreviewPath) await remove(tmpPreviewPath)
@ -213,14 +212,16 @@ export async function onWebVideoFileTranscoding (options: {
await createTorrentAndSetInfoHash(video, videoFile) await createTorrentAndSetInfoHash(video, videoFile)
const oldFile = await VideoFileModel.loadWebVideoFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution })
if (oldFile) await video.removeWebVideoFile(oldFile)
if (deleteWebInputVideoFile) { if (deleteWebInputVideoFile) {
await saveNewOriginalFileIfNeeded(video, deleteWebInputVideoFile)
await video.removeWebVideoFile(deleteWebInputVideoFile) await video.removeWebVideoFile(deleteWebInputVideoFile)
await deleteWebInputVideoFile.destroy() await deleteWebInputVideoFile.destroy()
} }
const existingFile = await VideoFileModel.loadWebVideoFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution })
if (existingFile) await video.removeWebVideoFile(existingFile)
await VideoFileModel.customUpsert(videoFile, 'video', undefined) await VideoFileModel.customUpsert(videoFile, 'video', undefined)
video.VideoFiles = await video.$get('VideoFiles') video.VideoFiles = await video.$get('VideoFiles')

View File

@ -1,8 +1,19 @@
import { VideoModel } from '@server/models/video/video.js' import { pick } from '@peertube/peertube-core-utils'
import { ActivityCreate, FileStorage, VideoExportJSON, VideoObject, VideoPrivacy } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { USER_EXPORT_MAX_ITEMS } from '@server/initializers/constants.js'
import { audiencify, getAudience } from '@server/lib/activitypub/audience.js'
import { buildCreateActivity } from '@server/lib/activitypub/send/send-create.js'
import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js'
import { getHLSFileReadStream, getOriginalFileReadStream, getWebVideoFileReadStream } from '@server/lib/object-storage/videos.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js' import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js' import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
import { VideoLiveModel } from '@server/models/video/video-live.js' import { VideoLiveModel } from '@server/models/video/video-live.js'
import { ExportResult, AbstractUserExporter } from './abstract-user-exporter.js' import { VideoPasswordModel } from '@server/models/video/video-password.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { VideoModel } from '@server/models/video/video.js'
import { import {
MStreamingPlaylistFiles, MStreamingPlaylistFiles,
MThumbnail, MVideo, MVideoAP, MVideoCaption, MThumbnail, MVideo, MVideoAP, MVideoCaption,
@ -12,23 +23,12 @@ import {
MVideoFullLight, MVideoLiveWithSetting, MVideoFullLight, MVideoLiveWithSetting,
MVideoPassword MVideoPassword
} from '@server/types/models/index.js' } from '@server/types/models/index.js'
import { logger } from '@server/helpers/logger.js' import { MVideoSource } from '@server/types/models/video/video-source.js'
import { ActivityCreate, VideoExportJSON, VideoObject, VideoPrivacy, FileStorage } from '@peertube/peertube-models'
import Bluebird from 'bluebird' import Bluebird from 'bluebird'
import { getHLSFileReadStream, getWebVideoFileReadStream } from '@server/lib/object-storage/videos.js'
import { createReadStream } from 'fs' import { createReadStream } from 'fs'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { extname, join } from 'path' import { extname, join } from 'path'
import { Readable } from 'stream' import { Readable } from 'stream'
import { getAudience, audiencify } from '@server/lib/activitypub/audience.js' import { AbstractUserExporter, ExportResult } from './abstract-user-exporter.js'
import { buildCreateActivity } from '@server/lib/activitypub/send/send-create.js'
import { pick } from '@peertube/peertube-core-utils'
import { VideoPasswordModel } from '@server/models/video/video-password.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js'
import { USER_EXPORT_MAX_ITEMS } from '@server/initializers/constants.js'
export class VideosExporter extends AbstractUserExporter <VideoExportJSON> { export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
@ -89,7 +89,7 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
// Then fetch more attributes for AP serialization // Then fetch more attributes for AP serialization
const videoAP = await video.lightAPToFullAP(undefined) const videoAP = await video.lightAPToFullAP(undefined)
const { relativePathsFromJSON, staticFiles } = this.exportVideoFiles({ video, captions }) const { relativePathsFromJSON, staticFiles } = await this.exportVideoFiles({ video, captions })
return { return {
json: this.exportVideoJSON({ video, captions, live, passwords, source, chapters, archiveFiles: relativePathsFromJSON }), json: this.exportVideoJSON({ video, captions, live, passwords, source, chapters, archiveFiles: relativePathsFromJSON }),
@ -168,9 +168,7 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
streamingPlaylists: this.exportStreamingPlaylistsJSON(video, video.VideoStreamingPlaylists), streamingPlaylists: this.exportStreamingPlaylistsJSON(video, video.VideoStreamingPlaylists),
source: source source: this.exportVideoSourceJSON(source),
? { filename: source.filename }
: null,
archiveFiles archiveFiles
} }
@ -228,6 +226,24 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
})) }))
} }
private exportVideoSourceJSON (source: MVideoSource) {
if (!source) return null
return {
inputFilename: source.inputFilename,
resolution: source.resolution,
size: source.size,
width: source.width,
height: source.height,
fps: source.fps,
metadata: source.metadata
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private async exportVideoAP (video: MVideoAP, chapters: MVideoChapter[]): Promise<ActivityCreate<VideoObject>> { private async exportVideoAP (video: MVideoAP, chapters: MVideoChapter[]): Promise<ActivityCreate<VideoObject>> {
@ -271,7 +287,7 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private exportVideoFiles (options: { private async exportVideoFiles (options: {
video: MVideoFullLight video: MVideoFullLight
captions: MVideoCaption[] captions: MVideoCaption[]
}) { }) {
@ -284,15 +300,27 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
captions: {} as { [ lang: string ]: string } captions: {} as { [ lang: string ]: string }
} }
const videoFile = video.getMaxQualityFile() if (this.options.withVideoFiles) {
const source = await VideoSourceModel.loadLatest(video.id)
const maxQualityFile = video.getMaxQualityFile()
if (this.options.withVideoFiles && videoFile) { // Prefer using original file if possible
staticFiles.push({ const file = source?.keptOriginalFilename
archivePath: this.getArchiveVideoFilePath(video, videoFile), ? source
createrReadStream: () => this.generateVideoFileReadStream(video, videoFile) : maxQualityFile
})
relativePathsFromJSON.videoFile = join(this.relativeStaticDirPath, this.getArchiveVideoFilePath(video, videoFile)) if (file) {
const videoPath = this.getArchiveVideoFilePath(video, file)
staticFiles.push({
archivePath: videoPath,
createrReadStream: () => file === source
? this.generateVideoSourceReadStream(source)
: this.generateVideoFileReadStream(video, maxQualityFile)
})
relativePathsFromJSON.videoFile = join(this.relativeStaticDirPath, videoPath)
}
} }
for (const caption of captions) { for (const caption of captions) {
@ -317,6 +345,16 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
return { staticFiles, relativePathsFromJSON } return { staticFiles, relativePathsFromJSON }
} }
private async generateVideoSourceReadStream (source: MVideoSource): Promise<Readable> {
if (source.storage === FileStorage.FILE_SYSTEM) {
return createReadStream(VideoPathManager.Instance.getFSOriginalVideoFilePath(source.keptOriginalFilename))
}
const { stream } = await getOriginalFileReadStream({ keptOriginalFilename: source.keptOriginalFilename, rangeHeader: undefined })
return stream
}
private async generateVideoFileReadStream (video: MVideoFullLight, videoFile: MVideoFile): Promise<Readable> { private async generateVideoFileReadStream (video: MVideoFullLight, videoFile: MVideoFile): Promise<Readable> {
if (videoFile.storage === FileStorage.FILE_SYSTEM) { if (videoFile.storage === FileStorage.FILE_SYSTEM) {
return createReadStream(VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)) return createReadStream(VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile))
@ -329,8 +367,8 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
return stream return stream
} }
private getArchiveVideoFilePath (video: MVideo, videoFile: MVideoFile) { private getArchiveVideoFilePath (video: MVideo, file: { filename?: string, keptOriginalFilename?: string }) {
return join('video-files', video.uuid + extname(videoFile.filename)) return join('video-files', video.uuid + extname(file.filename || file.keptOriginalFilename))
} }
private getArchiveCaptionFilePath (video: MVideo, caption: MVideoCaptionLanguageUrl) { private getArchiveCaptionFilePath (video: MVideo, caption: MVideoCaptionLanguageUrl) {

View File

@ -1,15 +1,12 @@
import { LiveVideoLatencyMode, ThumbnailType, VideoExportJSON, VideoPrivacy, VideoState } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
import { VideoModel } from '@server/models/video/video.js'
import { pick } from '@peertube/peertube-core-utils' import { pick } from '@peertube/peertube-core-utils'
import { buildUUID, getFileSize } from '@peertube/peertube-node-utils'
import { MChannelId, MVideoFullLight } from '@server/types/models/index.js'
import { ffprobePromise, getVideoStreamDuration } from '@peertube/peertube-ffmpeg' import { ffprobePromise, getVideoStreamDuration } from '@peertube/peertube-ffmpeg'
import { VideoChannelModel } from '@server/models/video/video-channel.js' import { LiveVideoLatencyMode, ThumbnailType, VideoExportJSON, VideoPrivacy, VideoState } from '@peertube/peertube-models'
import { AbstractUserImporter } from './abstract-user-importer.js' import { buildUUID, getFileSize } from '@peertube/peertube-node-utils'
import { isUserQuotaValid } from '@server/lib/user.js' import { isArray, isBooleanValid, isUUIDValid } from '@server/helpers/custom-validators/misc.js'
import { isVideoCaptionLanguageValid } from '@server/helpers/custom-validators/video-captions.js'
import { isVideoChannelUsernameValid } from '@server/helpers/custom-validators/video-channels.js'
import { isVideoChapterTimecodeValid, isVideoChapterTitleValid } from '@server/helpers/custom-validators/video-chapters.js'
import { isLiveLatencyModeValid } from '@server/helpers/custom-validators/video-lives.js'
import { import {
isPasswordValid, isPasswordValid,
isVideoCategoryValid, isVideoCategoryValid,
@ -25,17 +22,21 @@ import {
isVideoSupportValid, isVideoSupportValid,
isVideoTagValid isVideoTagValid
} from '@server/helpers/custom-validators/videos.js' } from '@server/helpers/custom-validators/videos.js'
import { isVideoChannelUsernameValid } from '@server/helpers/custom-validators/video-channels.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
import { isArray, isBooleanValid, isUUIDValid } from '@server/helpers/custom-validators/misc.js'
import { CONFIG } from '@server/initializers/config.js' import { CONFIG } from '@server/initializers/config.js'
import { isVideoCaptionLanguageValid } from '@server/helpers/custom-validators/video-captions.js' import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
import { isLiveLatencyModeValid } from '@server/helpers/custom-validators/video-lives.js'
import { parse } from 'path'
import { isLocalVideoFileAccepted } from '@server/lib/moderation.js'
import { LocalVideoCreator, ThumbnailOptions } from '@server/lib/local-video-creator.js' import { LocalVideoCreator, ThumbnailOptions } from '@server/lib/local-video-creator.js'
import { isVideoChapterTimecodeValid, isVideoChapterTitleValid } from '@server/helpers/custom-validators/video-chapters.js' import { isLocalVideoFileAccepted } from '@server/lib/moderation.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { isUserQuotaValid } from '@server/lib/user.js'
import { createLocalCaption } from '@server/lib/video-captions.js' import { createLocalCaption } from '@server/lib/video-captions.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { VideoModel } from '@server/models/video/video.js'
import { MChannelId, MVideoFullLight } from '@server/types/models/index.js'
import { FfprobeData } from 'fluent-ffmpeg'
import { parse } from 'path'
import { AbstractUserImporter } from './abstract-user-importer.js'
const lTags = loggerTagsFactory('user-import') const lTags = loggerTagsFactory('user-import')
@ -69,7 +70,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
if (!isBooleanValid(o.downloadEnabled)) o.downloadEnabled = CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED if (!isBooleanValid(o.downloadEnabled)) o.downloadEnabled = CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED
if (!isBooleanValid(o.waitTranscoding)) o.waitTranscoding = true if (!isBooleanValid(o.waitTranscoding)) o.waitTranscoding = true
if (!isVideoSourceFilenameValid(o.source?.filename)) o.source = undefined if (!isVideoSourceFilenameValid(o.source?.inputFilename)) o.source = undefined
if (!isVideoOriginallyPublishedAtValid(o.originallyPublishedAt)) o.originallyPublishedAt = null if (!isVideoOriginallyPublishedAtValid(o.originallyPublishedAt)) o.originallyPublishedAt = null
@ -149,6 +150,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
let duration = 0 let duration = 0
let ffprobe: FfprobeData
if (videoFilePath) { if (videoFilePath) {
if (await isUserQuotaValid({ userId: this.user.id, uploadSize: videoSize, checkDaily: false }) === false) { if (await isUserQuotaValid({ userId: this.user.id, uploadSize: videoSize, checkDaily: false }) === false) {
throw new Error(`Cannot import video ${videoImportData.name} for user ${this.user.username} because of exceeded quota`) throw new Error(`Cannot import video ${videoImportData.name} for user ${this.user.username} because of exceeded quota`)
@ -156,7 +158,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
await this.checkVideoFileIsAcceptedOrThrow({ videoFilePath, size: videoSize, channel: videoChannel, videoImportData }) await this.checkVideoFileIsAcceptedOrThrow({ videoFilePath, size: videoSize, channel: videoChannel, videoImportData })
const ffprobe = await ffprobePromise(videoFilePath) ffprobe = await ffprobePromise(videoFilePath)
duration = await getVideoStreamDuration(videoFilePath, ffprobe) duration = await getVideoStreamDuration(videoFilePath, ffprobe)
} }
@ -176,7 +178,11 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
const localVideoCreator = new LocalVideoCreator({ const localVideoCreator = new LocalVideoCreator({
lTags, lTags,
videoFilePath,
videoFile: videoFilePath
? { path: videoFilePath, probe: ffprobe }
: undefined,
user: this.user, user: this.user,
channel: videoChannel, channel: videoChannel,
@ -206,7 +212,9 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
videoPasswords: videoImportData.passwords, videoPasswords: videoImportData.passwords,
duration, duration,
filename: videoImportData.source?.filename,
inputFilename: videoImportData.source?.inputFilename,
state: videoImportData.isLive state: videoImportData.isLive
? VideoState.WAITING_FOR_LIVE ? VideoState.WAITING_FOR_LIVE
: buildNextVideoState() : buildNextVideoState()

View File

@ -48,7 +48,7 @@ export class UserExporter {
if (exportModel.storage === FileStorage.FILE_SYSTEM) { if (exportModel.storage === FileStorage.FILE_SYSTEM) {
output = createWriteStream(getFSUserExportFilePath(exportModel)) output = createWriteStream(getFSUserExportFilePath(exportModel))
endPromise = new Promise<void>(res => output.on('close', () => res())) endPromise = new Promise<string>(res => output.on('close', () => res('')))
} else { } else {
output = new PassThrough() output = new PassThrough()
endPromise = storeUserExportFile(output as PassThrough, exportModel) endPromise = storeUserExportFile(output as PassThrough, exportModel)
@ -56,12 +56,16 @@ export class UserExporter {
await this.createZip({ exportModel, user, output }) await this.createZip({ exportModel, user, output })
await endPromise const fileUrl = await endPromise
if (exportModel.storage === FileStorage.OBJECT_STORAGE) {
exportModel.fileUrl = fileUrl
exportModel.size = await getUserExportFileObjectStorageSize(exportModel)
} else if (exportModel.storage === FileStorage.FILE_SYSTEM) {
exportModel.size = await getFileSize(getFSUserExportFilePath(exportModel))
}
exportModel.state = UserExportState.COMPLETED exportModel.state = UserExportState.COMPLETED
exportModel.size = exportModel.storage === FileStorage.FILE_SYSTEM
? await getFileSize(getFSUserExportFilePath(exportModel))
: await getUserExportFileObjectStorageSize(exportModel)
await saveInTransactionWithRetries(exportModel) await saveInTransactionWithRetries(exportModel)
} catch (err) { } catch (err) {

View File

@ -1,16 +1,20 @@
import { FfprobeData } from 'fluent-ffmpeg'
import { VideoFileMetadata, VideoResolution } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { VideoFileModel } from '@server/models/video/video-file.js'
import { MVideoWithAllFiles } from '@server/types/models/index.js'
import { getFileSize, getLowercaseExtension } from '@peertube/peertube-node-utils'
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, isAudioFile } from '@peertube/peertube-ffmpeg' import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, isAudioFile } from '@peertube/peertube-ffmpeg'
import { FileStorage, VideoFileMetadata, VideoResolution } from '@peertube/peertube-models'
import { getFileSize, getLowercaseExtension } from '@peertube/peertube-node-utils'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { MIMETYPES } from '@server/initializers/constants.js'
import { VideoFileModel } from '@server/models/video/video-file.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { MVideo, MVideoFile, MVideoId, MVideoWithAllFiles } from '@server/types/models/index.js'
import { FfprobeData } from 'fluent-ffmpeg'
import { move, remove } from 'fs-extra'
import { lTags } from './object-storage/shared/index.js' import { lTags } from './object-storage/shared/index.js'
import { storeOriginalVideoFile } from './object-storage/videos.js'
import { generateHLSVideoFilename, generateWebVideoFilename } from './paths.js' import { generateHLSVideoFilename, generateWebVideoFilename } from './paths.js'
import { VideoPathManager } from './video-path-manager.js' import { VideoPathManager } from './video-path-manager.js'
import { MIMETYPES } from '@server/initializers/constants.js'
async function buildNewFile (options: { export async function buildNewFile (options: {
path: string path: string
mode: 'web-video' | 'hls' mode: 'web-video' | 'hls'
ffprobe?: FfprobeData ffprobe?: FfprobeData
@ -48,7 +52,7 @@ async function buildNewFile (options: {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function removeHLSPlaylist (video: MVideoWithAllFiles) { export async function removeHLSPlaylist (video: MVideoWithAllFiles) {
const hls = video.getHLSPlaylist() const hls = video.getHLSPlaylist()
if (!hls) return if (!hls) return
@ -64,7 +68,7 @@ async function removeHLSPlaylist (video: MVideoWithAllFiles) {
} }
} }
async function removeHLSFile (video: MVideoWithAllFiles, fileToDeleteId: number) { export async function removeHLSFile (video: MVideoWithAllFiles, fileToDeleteId: number) {
logger.info('Deleting HLS file %d of %s.', fileToDeleteId, video.url, lTags(video.uuid)) logger.info('Deleting HLS file %d of %s.', fileToDeleteId, video.url, lTags(video.uuid))
const hls = video.getHLSPlaylist() const hls = video.getHLSPlaylist()
@ -92,7 +96,7 @@ async function removeHLSFile (video: MVideoWithAllFiles, fileToDeleteId: number)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function removeAllWebVideoFiles (video: MVideoWithAllFiles) { export async function removeAllWebVideoFiles (video: MVideoWithAllFiles) {
const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
try { try {
@ -109,7 +113,7 @@ async function removeAllWebVideoFiles (video: MVideoWithAllFiles) {
return video return video
} }
async function removeWebVideoFile (video: MVideoWithAllFiles, fileToDeleteId: number) { export async function removeWebVideoFile (video: MVideoWithAllFiles, fileToDeleteId: number) {
const files = video.VideoFiles const files = video.VideoFiles
if (files.length === 1) { if (files.length === 1) {
@ -132,13 +136,13 @@ async function removeWebVideoFile (video: MVideoWithAllFiles, fileToDeleteId: nu
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function buildFileMetadata (path: string, existingProbe?: FfprobeData) { export async function buildFileMetadata (path: string, existingProbe?: FfprobeData) {
const metadata = existingProbe || await ffprobePromise(path) const metadata = existingProbe || await ffprobePromise(path)
return new VideoFileMetadata(metadata) return new VideoFileMetadata(metadata)
} }
function getVideoFileMimeType (extname: string, isAudio: boolean) { export function getVideoFileMimeType (extname: string, isAudio: boolean) {
return isAudio && extname === '.mp4' // We use .mp4 even for audio file only return isAudio && extname === '.mp4' // We use .mp4 even for audio file only
? MIMETYPES.AUDIO.EXT_MIMETYPE['.m4a'] ? MIMETYPES.AUDIO.EXT_MIMETYPE['.m4a']
: MIMETYPES.VIDEO.EXT_MIMETYPE[extname] : MIMETYPES.VIDEO.EXT_MIMETYPE[extname]
@ -146,14 +150,84 @@ function getVideoFileMimeType (extname: string, isAudio: boolean) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export async function createVideoSource (options: {
buildNewFile, inputFilename: string
inputProbe: FfprobeData
inputPath: string
video: MVideoId
createdAt?: Date
}) {
const { inputFilename, inputPath, inputProbe, video, createdAt } = options
removeHLSPlaylist, const videoSource = new VideoSourceModel({
removeHLSFile, inputFilename,
removeAllWebVideoFiles, videoId: video.id,
removeWebVideoFile, createdAt
})
buildFileMetadata, if (inputPath) {
getVideoFileMimeType const probe = inputProbe ?? await ffprobePromise(inputPath)
if (await isAudioFile(inputPath, probe)) {
videoSource.fps = 0
videoSource.resolution = VideoResolution.H_NOVIDEO
videoSource.width = 0
videoSource.height = 0
} else {
const dimensions = await getVideoStreamDimensionsInfo(inputPath, probe)
videoSource.fps = await getVideoStreamFPS(inputPath, probe)
videoSource.resolution = dimensions.resolution
videoSource.width = dimensions.width
videoSource.height = dimensions.height
}
videoSource.metadata = await buildFileMetadata(inputPath, probe)
videoSource.size = await getFileSize(inputPath)
}
return videoSource.save()
}
export async function saveNewOriginalFileIfNeeded (video: MVideo, videoFile: MVideoFile) {
if (!CONFIG.TRANSCODING.ORIGINAL_FILE.KEEP) return
const videoSource = await VideoSourceModel.loadLatest(video.id)
// Already have saved an original file
if (!videoSource || videoSource.keptOriginalFilename) return
videoSource.keptOriginalFilename = videoFile.filename
const lTags = loggerTagsFactory(video.uuid)
logger.info(`Storing original video file ${videoSource.keptOriginalFilename} of video ${video.name}`, lTags())
const sourcePath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
if (CONFIG.OBJECT_STORAGE.ENABLED) {
const fileUrl = await storeOriginalVideoFile(sourcePath, videoSource.keptOriginalFilename)
await remove(sourcePath)
videoSource.storage = FileStorage.OBJECT_STORAGE
videoSource.fileUrl = fileUrl
} else {
const destinationPath = VideoPathManager.Instance.getFSOriginalVideoFilePath(videoSource.keptOriginalFilename)
await move(sourcePath, destinationPath)
videoSource.storage = FileStorage.FILE_SYSTEM
}
await videoSource.save()
// Delete previously kept video files
const allSources = await VideoSourceModel.listAll(video.id)
for (const oldSource of allSources) {
if (!oldSource.keptOriginalFilename) continue
if (oldSource.id === videoSource.id) continue
try {
await video.removeOriginalFile(oldSource)
} catch (err) {
logger.error('Cannot delete old original file ' + oldSource.keptOriginalFilename, { err, ...lTags() })
}
}
} }

View File

@ -1,7 +1,5 @@
import { Mutex } from 'async-mutex'
import { remove } from 'fs-extra/esm'
import { extname, join } from 'path'
import { FileStorage } from '@peertube/peertube-models' import { FileStorage } from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { extractVideo } from '@server/helpers/video.js' import { extractVideo } from '@server/helpers/video.js'
import { CONFIG } from '@server/initializers/config.js' import { CONFIG } from '@server/initializers/config.js'
@ -13,7 +11,9 @@ import {
MVideoFileStreamingPlaylistVideo, MVideoFileStreamingPlaylistVideo,
MVideoFileVideo MVideoFileVideo
} from '@server/types/models/index.js' } from '@server/types/models/index.js'
import { buildUUID } from '@peertube/peertube-node-utils' import { Mutex } from 'async-mutex'
import { remove } from 'fs-extra/esm'
import { extname, join } from 'path'
import { makeHLSFileAvailable, makeWebVideoFileAvailable } from './object-storage/index.js' import { makeHLSFileAvailable, makeWebVideoFileAvailable } from './object-storage/index.js'
import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from './paths.js' import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from './paths.js'
import { isVideoInPrivateDirectory } from './video-privacy.js' import { isVideoInPrivateDirectory } from './video-privacy.js'
@ -56,10 +56,14 @@ class VideoPathManager {
} }
if (isVideoInPrivateDirectory(video.privacy)) { if (isVideoInPrivateDirectory(video.privacy)) {
return join(DIRECTORIES.VIDEOS.PRIVATE, videoFile.filename) return join(DIRECTORIES.WEB_VIDEOS.PRIVATE, videoFile.filename)
} }
return join(DIRECTORIES.VIDEOS.PUBLIC, videoFile.filename) return join(DIRECTORIES.WEB_VIDEOS.PUBLIC, videoFile.filename)
}
getFSOriginalVideoFilePath (filename: string) {
return join(DIRECTORIES.ORIGINAL_VIDEOS, filename)
} }
async makeAvailableVideoFile <T> (videoFile: MVideoFileVideo | MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB<T>) { async makeAvailableVideoFile <T> (videoFile: MVideoFileVideo | MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB<T>) {

View File

@ -101,10 +101,10 @@ async function moveWebVideoFileOnFS (type: MoveType, video: MVideo, file: MVideo
function getWebVideoDirectories (moveType: MoveType) { function getWebVideoDirectories (moveType: MoveType) {
if (moveType === 'private-to-public') { if (moveType === 'private-to-public') {
return { old: DIRECTORIES.VIDEOS.PRIVATE, new: DIRECTORIES.VIDEOS.PUBLIC } return { old: DIRECTORIES.WEB_VIDEOS.PRIVATE, new: DIRECTORIES.WEB_VIDEOS.PUBLIC }
} }
return { old: DIRECTORIES.VIDEOS.PUBLIC, new: DIRECTORIES.VIDEOS.PRIVATE } return { old: DIRECTORIES.WEB_VIDEOS.PUBLIC, new: DIRECTORIES.WEB_VIDEOS.PRIVATE }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -41,6 +41,7 @@ const customConfigUpdateValidator = [
body('videoChannels.maxPerUser').isInt(), body('videoChannels.maxPerUser').isInt(),
body('transcoding.enabled').isBoolean(), body('transcoding.enabled').isBoolean(),
body('transcoding.originalFile.keep').isBoolean(),
body('transcoding.allowAdditionalExtensions').isBoolean(), body('transcoding.allowAdditionalExtensions').isBoolean(),
body('transcoding.threads').isInt(), body('transcoding.threads').isInt(),
body('transcoding.concurrency').isInt({ min: 1 }), body('transcoding.concurrency').isInt({ min: 1 }),

View File

@ -1,7 +1,6 @@
import { Request, Response } from 'express'
import { HttpStatusCode, ServerErrorCode, UserRight, UserRightType, VideoPrivacy } from '@peertube/peertube-models' import { HttpStatusCode, ServerErrorCode, UserRight, UserRightType, VideoPrivacy } from '@peertube/peertube-models'
import { exists } from '@server/helpers/custom-validators/misc.js' import { exists } from '@server/helpers/custom-validators/misc.js'
import { loadVideo, VideoLoadType } from '@server/lib/model-loaders/index.js' import { VideoLoadType, loadVideo } from '@server/lib/model-loaders/index.js'
import { isUserQuotaValid } from '@server/lib/user.js' import { isUserQuotaValid } from '@server/lib/user.js'
import { VideoTokensManager } from '@server/lib/video-tokens-manager.js' import { VideoTokensManager } from '@server/lib/video-tokens-manager.js'
import { authenticatePromise } from '@server/middlewares/auth.js' import { authenticatePromise } from '@server/middlewares/auth.js'
@ -20,10 +19,12 @@ import {
MVideoId, MVideoId,
MVideoImmutable, MVideoImmutable,
MVideoThumbnail, MVideoThumbnail,
MVideoUUID,
MVideoWithRights MVideoWithRights
} from '@server/types/models/index.js' } from '@server/types/models/index.js'
import { Request, Response } from 'express'
async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { export async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
const video = await loadVideo(id, fetchType, userId) const video = await loadVideo(id, fetchType, userId)
@ -64,7 +65,7 @@ async function doesVideoExist (id: number | string, res: Response, fetchType: Vi
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function doesVideoFileOfVideoExist (id: number, videoIdOrUUID: number | string, res: Response) { export async function doesVideoFileOfVideoExist (id: number, videoIdOrUUID: number | string, res: Response) {
if (!await VideoFileModel.doesVideoExistForVideoFile(id, videoIdOrUUID)) { if (!await VideoFileModel.doesVideoExistForVideoFile(id, videoIdOrUUID)) {
res.fail({ res.fail({
status: HttpStatusCode.NOT_FOUND_404, status: HttpStatusCode.NOT_FOUND_404,
@ -78,7 +79,7 @@ async function doesVideoFileOfVideoExist (id: number, videoIdOrUUID: number | st
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAccountId, res: Response) { export async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAccountId, res: Response) {
const videoChannel = await VideoChannelModel.loadAndPopulateAccount(channelId) const videoChannel = await VideoChannelModel.loadAndPopulateAccount(channelId)
if (videoChannel === null) { if (videoChannel === null) {
@ -105,7 +106,7 @@ async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAcc
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function checkCanSeeVideo (options: { export async function checkCanSeeVideo (options: {
req: Request req: Request
res: Response res: Response
paramId: string paramId: string
@ -128,7 +129,7 @@ async function checkCanSeeVideo (options: {
throw new Error('Unknown video privacy when checking video right ' + video.url) throw new Error('Unknown video privacy when checking video right ' + video.url)
} }
async function checkCanSeeUserAuthVideo (options: { export async function checkCanSeeUserAuthVideo (options: {
req: Request req: Request
res: Response res: Response
video: MVideoId | MVideoWithRights video: MVideoId | MVideoWithRights
@ -174,7 +175,7 @@ async function checkCanSeeUserAuthVideo (options: {
return fail() return fail()
} }
async function checkCanSeePasswordProtectedVideo (options: { export async function checkCanSeePasswordProtectedVideo (options: {
req: Request req: Request
res: Response res: Response
video: MVideo video: MVideo
@ -215,13 +216,13 @@ async function checkCanSeePasswordProtectedVideo (options: {
return false return false
} }
function canUserAccessVideo (user: MUser, video: MVideoWithRights | MVideoAccountLight, right: UserRightType) { export function canUserAccessVideo (user: MUser, video: MVideoWithRights | MVideoAccountLight, right: UserRightType) {
const isOwnedByUser = video.VideoChannel.Account.userId === user.id const isOwnedByUser = video.VideoChannel.Account.userId === user.id
return isOwnedByUser || user.hasRight(right) return isOwnedByUser || user.hasRight(right)
} }
async function getVideoWithRights (video: MVideoWithRights): Promise<MVideoWithRights> { export async function getVideoWithRights (video: MVideoWithRights): Promise<MVideoWithRights> {
return video.VideoChannel?.Account?.userId return video.VideoChannel?.Account?.userId
? video ? video
: VideoModel.loadFull(video.id) : VideoModel.loadFull(video.id)
@ -229,7 +230,7 @@ async function getVideoWithRights (video: MVideoWithRights): Promise<MVideoWithR
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function checkCanAccessVideoStaticFiles (options: { export async function checkCanAccessVideoStaticFiles (options: {
video: MVideo video: MVideo
req: Request req: Request
res: Response res: Response
@ -241,23 +242,51 @@ async function checkCanAccessVideoStaticFiles (options: {
return checkCanSeeVideo(options) return checkCanSeeVideo(options)
} }
const videoFileToken = req.query.videoFileToken assignVideoTokenIfNeeded(req, res, video)
if (videoFileToken && VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) {
const user = VideoTokensManager.Instance.getUserFromToken({ token: videoFileToken })
res.locals.videoFileToken = { user }
return true
}
if (res.locals.videoFileToken) return true
if (!video.hasPrivateStaticPath()) return true if (!video.hasPrivateStaticPath()) return true
res.sendStatus(HttpStatusCode.FORBIDDEN_403) res.sendStatus(HttpStatusCode.FORBIDDEN_403)
return false return false
} }
export async function checkCanAccessVideoSourceFile (options: {
videoId: number
req: Request
res: Response
}) {
const { req, res, videoId } = options
const video = await VideoModel.loadFull(videoId)
if (res.locals.oauth?.token.User) {
if (canUserAccessVideo(res.locals.oauth.token.User, video, UserRight.SEE_ALL_VIDEOS) === true) return true
res.sendStatus(HttpStatusCode.FORBIDDEN_403)
return false
}
assignVideoTokenIfNeeded(req, res, video)
if (res.locals.videoFileToken) return true
res.sendStatus(HttpStatusCode.FORBIDDEN_403)
return false
}
function assignVideoTokenIfNeeded (req: Request, res: Response, video: MVideoUUID) {
const videoFileToken = req.query.videoFileToken
if (videoFileToken && VideoTokensManager.Instance.hasToken({ token: videoFileToken, videoUUID: video.uuid })) {
const user = VideoTokensManager.Instance.getUserFromToken({ token: videoFileToken })
res.locals.videoFileToken = { user }
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRightType, res: Response, onlyOwned = true) { export function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRightType, res: Response, onlyOwned = true) {
// Retrieve the user who did the request // Retrieve the user who did the request
if (onlyOwned && video.isOwned() === false) { if (onlyOwned && video.isOwned() === false) {
res.fail({ res.fail({
@ -284,7 +313,7 @@ function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right:
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function checkUserQuota (user: MUserId, videoFileSize: number, res: Response) { export async function checkUserQuota (user: MUserId, videoFileSize: number, res: Response) {
if (await isUserQuotaValid({ userId: user.id, uploadSize: videoFileSize }) === false) { if (await isUserQuotaValid({ userId: user.id, uploadSize: videoFileSize }) === false) {
res.fail({ res.fail({
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413, status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
@ -296,16 +325,3 @@ async function checkUserQuota (user: MUserId, videoFileSize: number, res: Respon
return true return true
} }
// ---------------------------------------------------------------------------
export {
doesVideoChannelOfAccountExist,
doesVideoExist,
doesVideoFileOfVideoExist,
checkCanAccessVideoStaticFiles,
checkUserCanManageVideo,
checkCanSeeVideo,
checkUserQuota
}

View File

@ -1,6 +1,6 @@
import express from 'express' import express from 'express'
import { logger } from '@server/helpers/logger.js' import { logger } from '@server/helpers/logger.js'
import { getVideoStreamDuration } from '@peertube/peertube-ffmpeg' import { ffprobePromise, getVideoStreamDuration } from '@peertube/peertube-ffmpeg'
import { HttpStatusCode } from '@peertube/peertube-models' import { HttpStatusCode } from '@peertube/peertube-models'
export async function addDurationToVideoFileIfNeeded (options: { export async function addDurationToVideoFileIfNeeded (options: {
@ -11,7 +11,7 @@ export async function addDurationToVideoFileIfNeeded (options: {
const { res, middlewareName, videoFile } = options const { res, middlewareName, videoFile } = options
try { try {
if (!videoFile.duration) await addDurationToVideo(videoFile) if (!videoFile.duration) await addDurationToVideo(res, videoFile)
} catch (err) { } catch (err) {
logger.error('Invalid input file in ' + middlewareName, { err }) logger.error('Invalid input file in ' + middlewareName, { err })
@ -29,8 +29,11 @@ export async function addDurationToVideoFileIfNeeded (options: {
// Private // Private
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function addDurationToVideo (videoFile: { path: string, duration?: number }) { async function addDurationToVideo (res: express.Response, videoFile: { path: string, duration?: number }) {
const duration = await getVideoStreamDuration(videoFile.path) const probe = await ffprobePromise(videoFile.path)
res.locals.ffprobe = probe
const duration = await getVideoStreamDuration(videoFile.path, probe)
// FFmpeg may not be able to guess video duration // FFmpeg may not be able to guess video duration
// For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2 // For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2

View File

@ -1,12 +1,19 @@
import express from 'express' import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { getVideoWithAttributes } from '@server/helpers/video.js' import { getVideoWithAttributes } from '@server/helpers/video.js'
import { CONFIG } from '@server/initializers/config.js' import { CONFIG } from '@server/initializers/config.js'
import { buildUploadXFile, safeUploadXCleanup } from '@server/lib/uploadx.js' import { buildUploadXFile, safeUploadXCleanup } from '@server/lib/uploadx.js'
import { VideoSourceModel } from '@server/models/video/video-source.js' import { VideoSourceModel } from '@server/models/video/video-source.js'
import { MVideoFullLight } from '@server/types/models/index.js' import { MVideoFullLight } from '@server/types/models/index.js'
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { Metadata as UploadXMetadata } from '@uploadx/core' import { Metadata as UploadXMetadata } from '@uploadx/core'
import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js' import express from 'express'
import { param } from 'express-validator'
import {
areValidationErrors,
checkCanAccessVideoSourceFile,
checkUserCanManageVideo,
doesVideoExist,
isValidVideoIdParam
} from '../shared/index.js'
import { addDurationToVideoFileIfNeeded, checkVideoFileCanBeEdited, commonVideoFileChecks, isVideoFileAccepted } from './shared/index.js' import { addDurationToVideoFileIfNeeded, checkVideoFileCanBeEdited, commonVideoFileChecks, isVideoFileAccepted } from './shared/index.js'
export const videoSourceGetLatestValidator = [ export const videoSourceGetLatestValidator = [
@ -71,6 +78,28 @@ export const replaceVideoSourceResumableInitValidator = [
} }
] ]
export const originalVideoFileDownloadValidator = [
param('filename').exists(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const videoSource = await VideoSourceModel.loadByKeptOriginalFilename(req.params.filename)
if (!videoSource) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Original video file not found'
})
}
if (!await checkCanAccessVideoSourceFile({ req, res, videoId: videoSource.videoId })) return
res.locals.videoSource = videoSource
return next()
}
]
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Private // Private
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,5 +1,3 @@
import express from 'express'
import { body, param, query, ValidationChain } from 'express-validator'
import { arrayify } from '@peertube/peertube-core-utils' import { arrayify } from '@peertube/peertube-core-utils'
import { HttpStatusCode, ServerErrorCode, UserRight, VideoState } from '@peertube/peertube-models' import { HttpStatusCode, ServerErrorCode, UserRight, VideoState } from '@peertube/peertube-models'
import { Redis } from '@server/lib/redis.js' import { Redis } from '@server/lib/redis.js'
@ -7,6 +5,8 @@ import { buildUploadXFile, safeUploadXCleanup } from '@server/lib/uploadx.js'
import { getServerActor } from '@server/models/application/application.js' import { getServerActor } from '@server/models/application/application.js'
import { ExpressPromiseHandler } from '@server/types/express-handler.js' import { ExpressPromiseHandler } from '@server/types/express-handler.js'
import { MUserAccountId, MVideoFullLight } from '@server/types/models/index.js' import { MUserAccountId, MVideoFullLight } from '@server/types/models/index.js'
import express from 'express'
import { ValidationChain, body, param, query } from 'express-validator'
import { import {
exists, exists,
isBooleanValid, isBooleanValid,
@ -41,8 +41,7 @@ import { CONFIG } from '../../../initializers/config.js'
import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants.js' import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants.js'
import { VideoModel } from '../../../models/video/video.js' import { VideoModel } from '../../../models/video/video.js'
import { import {
areValidationErrors, areValidationErrors, checkCanAccessVideoStaticFiles,
checkCanAccessVideoStaticFiles,
checkCanSeeVideo, checkCanSeeVideo,
checkUserCanManageVideo, checkUserCanManageVideo,
doesVideoChannelOfAccountExist, doesVideoChannelOfAccountExist,
@ -501,23 +500,19 @@ const commonVideosFiltersValidator = [
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
videosAddLegacyValidator,
videosAddResumableValidator,
videosAddResumableInitValidator,
videosUpdateValidator,
videosGetValidator,
videoFileMetadataGetValidator,
videosDownloadValidator,
checkVideoFollowConstraints, checkVideoFollowConstraints,
videosCustomGetValidator,
videosRemoveValidator,
getCommonVideoEditAttributes,
commonVideosFiltersValidator, commonVideosFiltersValidator,
getCommonVideoEditAttributes,
videosOverviewValidator videoFileMetadataGetValidator,
videosAddLegacyValidator,
videosAddResumableInitValidator,
videosAddResumableValidator,
videosCustomGetValidator,
videosDownloadValidator,
videosGetValidator,
videosOverviewValidator,
videosRemoveValidator,
videosUpdateValidator
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -62,6 +62,10 @@ export class UserExportModel extends SequelizeModel<UserExportModel> {
@Column @Column
storage: FileStorageType storage: FileStorageType
@AllowNull(true)
@Column
fileUrl: string
@ForeignKey(() => UserModel) @ForeignKey(() => UserModel)
@Column @Column
userId: number userId: number
@ -188,7 +192,7 @@ export class UserExportModel extends SequelizeModel<UserExportModel> {
getFileDownloadUrl () { getFileDownloadUrl () {
if (this.state !== UserExportState.COMPLETED) return null if (this.state !== UserExportState.COMPLETED) return null
return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.USER_EXPORT, this.filename) + '?jwt=' + this.generateJWT() return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.USER_EXPORTS, this.filename) + '?jwt=' + this.generateJWT()
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,8 +1,3 @@
import { generateMagnetUri } from '@server/helpers/webtorrent.js'
import { tracer } from '@server/lib/opentelemetry/tracing.js'
import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls.js'
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
import { uuidToShort } from '@peertube/peertube-node-utils'
import { import {
Video, Video,
VideoAdditionalAttributes, VideoAdditionalAttributes,
@ -12,6 +7,11 @@ import {
VideosCommonQueryAfterSanitize, VideosCommonQueryAfterSanitize,
VideoStreamingPlaylist VideoStreamingPlaylist
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { uuidToShort } from '@peertube/peertube-node-utils'
import { generateMagnetUri } from '@server/helpers/webtorrent.js'
import { tracer } from '@server/lib/opentelemetry/tracing.js'
import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls.js'
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
import { isArray } from '../../../helpers/custom-validators/misc.js' import { isArray } from '../../../helpers/custom-validators/misc.js'
import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, VIDEO_STATES } from '../../../initializers/constants.js' import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, VIDEO_STATES } from '../../../initializers/constants.js'
import { MServer, MStreamingPlaylistRedundanciesOpt, MVideoFormattable, MVideoFormattableDetails } from '../../../types/models/index.js' import { MServer, MStreamingPlaylistRedundanciesOpt, MVideoFormattable, MVideoFormattableDetails } from '../../../types/models/index.js'
@ -211,9 +211,7 @@ export function videoFilesModelToFormattedJSON (
resolution: { resolution: {
id: videoFile.resolution, id: videoFile.resolution,
label: videoFile.resolution === 0 label: getResolutionLabel(videoFile.resolution)
? 'Audio'
: `${videoFile.resolution}p`
}, },
width: videoFile.width, width: videoFile.width,
@ -259,6 +257,12 @@ export function getStateLabel (id: number) {
return VIDEO_STATES[id] || 'Unknown' return VIDEO_STATES[id] || 'Unknown'
} }
export function getResolutionLabel (resolution: number) {
if (resolution === 0) return 'Audio'
return `${resolution}p`
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Private // Private
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,15 +1,15 @@
import { ActivityVideoUrlObject, VideoResolution, FileStorage, type FileStorageType } from '@peertube/peertube-models' import { ActivityVideoUrlObject, FileStorage, VideoResolution, type FileStorageType } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js' import { logger } from '@server/helpers/logger.js'
import { extractVideo } from '@server/helpers/video.js' import { extractVideo } from '@server/helpers/video.js'
import { CONFIG } from '@server/initializers/config.js' import { CONFIG } from '@server/initializers/config.js'
import { buildRemoteUrl } from '@server/lib/activitypub/url.js' import { buildRemoteUrl } from '@server/lib/activitypub/url.js'
import { import {
getHLSPrivateFileUrl, getHLSPrivateFileUrl,
getHLSPublicFileUrl, getObjectStoragePublicFileUrl,
getWebVideoPrivateFileUrl, getWebVideoPrivateFileUrl
getWebVideoPublicFileUrl
} from '@server/lib/object-storage/index.js' } from '@server/lib/object-storage/index.js'
import { getFSTorrentFilePath } from '@server/lib/paths.js' import { getFSTorrentFilePath } from '@server/lib/paths.js'
import { getVideoFileMimeType } from '@server/lib/video-file.js'
import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js' import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js'
import { MStreamingPlaylistVideo, MVideo, MVideoWithHost, isStreamingPlaylist } from '@server/types/models/index.js' import { MStreamingPlaylistVideo, MVideo, MVideoWithHost, isStreamingPlaylist } from '@server/types/models/index.js'
import { remove } from 'fs-extra/esm' import { remove } from 'fs-extra/esm'
@ -51,7 +51,6 @@ import { VideoRedundancyModel } from '../redundancy/video-redundancy.js'
import { SequelizeModel, doesExist, parseAggregateResult, throwIfNotValid } from '../shared/index.js' import { SequelizeModel, doesExist, parseAggregateResult, throwIfNotValid } from '../shared/index.js'
import { VideoStreamingPlaylistModel } from './video-streaming-playlist.js' import { VideoStreamingPlaylistModel } from './video-streaming-playlist.js'
import { VideoModel } from './video.js' import { VideoModel } from './video.js'
import { getVideoFileMimeType } from '@server/lib/video-file.js'
export enum ScopeNames { export enum ScopeNames {
WITH_VIDEO = 'WITH_VIDEO', WITH_VIDEO = 'WITH_VIDEO',
@ -534,10 +533,10 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> {
private getPublicObjectStorageUrl () { private getPublicObjectStorageUrl () {
if (this.isHLS()) { if (this.isHLS()) {
return getHLSPublicFileUrl(this.fileUrl) return getObjectStoragePublicFileUrl(this.fileUrl, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
} }
return getWebVideoPublicFileUrl(this.fileUrl) return getObjectStoragePublicFileUrl(this.fileUrl, CONFIG.OBJECT_STORAGE.WEB_VIDEOS)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,8 +1,12 @@
import type { FileStorageType, VideoSource } from '@peertube/peertube-models'
import { STATIC_DOWNLOAD_PATHS, WEBSERVER } from '@server/initializers/constants.js'
import { join } from 'path'
import { Transaction } from 'sequelize' import { Transaction } from 'sequelize'
import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript' import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
import { VideoSource } from '@peertube/peertube-models'
import { SequelizeModel, getSort } from '../shared/index.js' import { SequelizeModel, getSort } from '../shared/index.js'
import { getResolutionLabel } from './formatter/video-api-format.js'
import { VideoModel } from './video.js' import { VideoModel } from './video.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
@Table({ @Table({
tableName: 'videoSource', tableName: 'videoSource',
@ -12,6 +16,10 @@ import { VideoModel } from './video.js'
}, },
{ {
fields: [ { name: 'createdAt', order: 'DESC' } ] fields: [ { name: 'createdAt', order: 'DESC' } ]
},
{
fields: [ 'keptOriginalFilename' ],
unique: true
} }
] ]
}) })
@ -24,7 +32,43 @@ export class VideoSourceModel extends SequelizeModel<VideoSourceModel> {
@AllowNull(false) @AllowNull(false)
@Column @Column
filename: string inputFilename: string
@AllowNull(true)
@Column
keptOriginalFilename: string
@AllowNull(true)
@Column
resolution: number
@AllowNull(true)
@Column
width: number
@AllowNull(true)
@Column
height: number
@AllowNull(true)
@Column
fps: number
@AllowNull(true)
@Column(DataType.BIGINT)
size: number
@AllowNull(true)
@Column(DataType.JSONB)
metadata: any
@AllowNull(true)
@Column
storage: FileStorageType
@AllowNull(true)
@Column
fileUrl: string
@ForeignKey(() => VideoModel) @ForeignKey(() => VideoModel)
@Column @Column
@ -39,16 +83,51 @@ export class VideoSourceModel extends SequelizeModel<VideoSourceModel> {
Video: Awaited<VideoModel> Video: Awaited<VideoModel>
static loadLatest (videoId: number, transaction?: Transaction) { static loadLatest (videoId: number, transaction?: Transaction) {
return VideoSourceModel.findOne({ return VideoSourceModel.findOne<MVideoSource>({
where: { videoId }, where: { videoId },
order: getSort('-createdAt'), order: getSort('-createdAt'),
transaction transaction
}) })
} }
static loadByKeptOriginalFilename (keptOriginalFilename: string) {
return VideoSourceModel.findOne<MVideoSource>({
where: { keptOriginalFilename }
})
}
static listAll (videoId: number, transaction?: Transaction) {
return VideoSourceModel.findAll<MVideoSource>({
where: { videoId },
transaction
})
}
getFileDownloadUrl () {
if (!this.keptOriginalFilename) return null
return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.ORIGINAL_VIDEO_FILE, this.keptOriginalFilename)
}
toFormattedJSON (): VideoSource { toFormattedJSON (): VideoSource {
return { return {
filename: this.filename, filename: this.inputFilename,
inputFilename: this.inputFilename,
fileDownloadUrl: this.getFileDownloadUrl(),
resolution: {
id: this.resolution,
label: getResolutionLabel(this.resolution)
},
size: this.size,
width: this.width,
height: this.height,
fps: this.fps,
metadata: this.metadata,
createdAt: this.createdAt.toISOString() createdAt: this.createdAt.toISOString()
} }
} }

View File

@ -6,7 +6,7 @@ import {
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { sha1 } from '@peertube/peertube-node-utils' import { sha1 } from '@peertube/peertube-node-utils'
import { CONFIG } from '@server/initializers/config.js' import { CONFIG } from '@server/initializers/config.js'
import { getHLSPrivateFileUrl, getHLSPublicFileUrl } from '@server/lib/object-storage/index.js' import { getHLSPrivateFileUrl, getObjectStoragePublicFileUrl } from '@server/lib/object-storage/index.js'
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths.js' import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths.js'
import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js' import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js'
import { VideoFileModel } from '@server/models/video/video-file.js' import { VideoFileModel } from '@server/models/video/video-file.js'
@ -266,7 +266,7 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
return getHLSPrivateFileUrl(video, this.playlistFilename) return getHLSPrivateFileUrl(video, this.playlistFilename)
} }
return getHLSPublicFileUrl(this.playlistUrl) return getObjectStoragePublicFileUrl(this.playlistUrl, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -288,7 +288,7 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
return getHLSPrivateFileUrl(video, this.segmentsSha256Filename) return getHLSPrivateFileUrl(video, this.segmentsSha256Filename)
} }
return getHLSPublicFileUrl(this.segmentsSha256Url) return getObjectStoragePublicFileUrl(this.segmentsSha256Url, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,6 +1,7 @@
import { buildVideoEmbedPath, buildVideoWatchPath, pick, wait } from '@peertube/peertube-core-utils' import { buildVideoEmbedPath, buildVideoWatchPath, pick, wait } from '@peertube/peertube-core-utils'
import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@peertube/peertube-ffmpeg' import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@peertube/peertube-ffmpeg'
import { import {
FileStorage,
ResultList, ResultList,
ThumbnailType, ThumbnailType,
UserRight, UserRight,
@ -13,7 +14,6 @@ import {
VideoPrivacy, VideoPrivacy,
VideoRateType, VideoRateType,
VideoState, VideoState,
FileStorage,
VideoStreamingPlaylistType, VideoStreamingPlaylistType,
type VideoPrivacyType, type VideoPrivacyType,
type VideoStateType type VideoStateType
@ -25,6 +25,7 @@ import { LiveManager } from '@server/lib/live/live-manager.js'
import { import {
removeHLSFileObjectStorageByFilename, removeHLSFileObjectStorageByFilename,
removeHLSObjectStorage, removeHLSObjectStorage,
removeOriginalFileObjectStorage,
removeWebVideoObjectStorage removeWebVideoObjectStorage
} from '@server/lib/object-storage/index.js' } from '@server/lib/object-storage/index.js'
import { tracer } from '@server/lib/opentelemetry/tracing.js' import { tracer } from '@server/lib/opentelemetry/tracing.js'
@ -34,6 +35,7 @@ import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js' import { isVideoInPrivateDirectory } from '@server/lib/video-privacy.js'
import { getServerActor } from '@server/models/application/application.js' import { getServerActor } from '@server/models/application/application.js'
import { ModelCache } from '@server/models/shared/model-cache.js' import { ModelCache } from '@server/models/shared/model-cache.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
import Bluebird from 'bluebird' import Bluebird from 'bluebird'
import { remove } from 'fs-extra/esm' import { remove } from 'fs-extra/esm'
import maxBy from 'lodash-es/maxBy.js' import maxBy from 'lodash-es/maxBy.js'
@ -867,6 +869,12 @@ export class VideoModel extends SequelizeModel<VideoModel> {
for (const p of instance.VideoStreamingPlaylists) { for (const p of instance.VideoStreamingPlaylists) {
tasks.push(instance.removeStreamingPlaylistFiles(p)) tasks.push(instance.removeStreamingPlaylistFiles(p))
} }
// Remove source files
const promiseRemoveSources = VideoSourceModel.listAll(instance.id, options.transaction)
.then(sources => Promise.all(sources.map(s => instance.removeOriginalFile(s))))
tasks.push(promiseRemoveSources)
} }
// Do not wait video deletion because we could be in a transaction // Do not wait video deletion because we could be in a transaction
@ -2022,6 +2030,17 @@ export class VideoModel extends SequelizeModel<VideoModel> {
} }
} }
async removeOriginalFile (videoSource: MVideoSource) {
if (!videoSource.keptOriginalFilename) return
const filePath = VideoPathManager.Instance.getFSOriginalVideoFilePath(videoSource.keptOriginalFilename)
await remove(filePath)
if (videoSource.storage === FileStorage.OBJECT_STORAGE) {
await removeOriginalFileObjectStorage(videoSource)
}
}
isOutdated () { isOutdated () {
if (this.isOwned()) return false if (this.isOwned()) return false

View File

@ -1,5 +1,3 @@
import { OutgoingHttpHeaders } from 'http'
import { Writable } from 'stream'
import { HttpMethodType, PeerTubeProblemDocumentData, VideoCreate } from '@peertube/peertube-models' import { HttpMethodType, PeerTubeProblemDocumentData, VideoCreate } from '@peertube/peertube-models'
import { RegisterServerAuthExternalOptions } from '@server/types/index.js' import { RegisterServerAuthExternalOptions } from '@server/types/index.js'
import { import {
@ -29,7 +27,10 @@ import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server.
import { MVideoImportDefault } from '@server/types/models/video/video-import.js' import { MVideoImportDefault } from '@server/types/models/video/video-import.js'
import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element.js' import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element.js'
import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate.js' import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate.js'
import { File as UploadXFile, Metadata } from '@uploadx/core' import { Metadata, File as UploadXFile } from '@uploadx/core'
import { FfprobeData } from 'fluent-ffmpeg'
import { OutgoingHttpHeaders } from 'http'
import { Writable } from 'stream'
import { RegisteredPlugin } from '../../lib/plugins/plugin-manager.js' import { RegisteredPlugin } from '../../lib/plugins/plugin-manager.js'
import { import {
MAccountDefault, MAccountDefault,
@ -127,6 +128,8 @@ declare module 'express' {
docUrl?: string docUrl?: string
ffprobe?: FfprobeData
videoAPI?: MVideoFormattableDetails videoAPI?: MVideoFormattableDetails
videoAll?: MVideoFullLight videoAll?: MVideoFullLight
onlyImmutableVideo?: MVideoImmutable onlyImmutableVideo?: MVideoImmutable

View File

@ -19,7 +19,7 @@ async function run () {
console.log('Moving private video files in dedicated folders.') console.log('Moving private video files in dedicated folders.')
await ensureDir(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE) await ensureDir(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE)
await ensureDir(DIRECTORIES.VIDEOS.PRIVATE) await ensureDir(DIRECTORIES.WEB_VIDEOS.PRIVATE)
await initDatabaseModels(true) await initDatabaseModels(true)

View File

@ -38,8 +38,8 @@ async function run () {
console.log('Detecting files to remove, it could take a while...') console.log('Detecting files to remove, it could take a while...')
toDelete = toDelete.concat( toDelete = toDelete.concat(
await pruneDirectory(DIRECTORIES.VIDEOS.PUBLIC, doesWebVideoFileExist()), await pruneDirectory(DIRECTORIES.WEB_VIDEOS.PUBLIC, doesWebVideoFileExist()),
await pruneDirectory(DIRECTORIES.VIDEOS.PRIVATE, doesWebVideoFileExist()), await pruneDirectory(DIRECTORIES.WEB_VIDEOS.PRIVATE, doesWebVideoFileExist()),
await pruneDirectory(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, doesHLSPlaylistExist()), await pruneDirectory(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, doesHLSPlaylistExist()),
await pruneDirectory(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, doesHLSPlaylistExist()), await pruneDirectory(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, doesHLSPlaylistExist()),
@ -97,7 +97,7 @@ async function pruneDirectory (directory: string, existFun: ExistFun) {
function doesWebVideoFileExist () { function doesWebVideoFileExist () {
return (filePath: string) => { return (filePath: string) => {
// Don't delete private directory // Don't delete private directory
if (filePath === DIRECTORIES.VIDEOS.PRIVATE) return true if (filePath === DIRECTORIES.WEB_VIDEOS.PRIVATE) return true
return VideoFileModel.doesOwnedWebVideoFileExist(basename(filePath)) return VideoFileModel.doesOwnedWebVideoFileExist(basename(filePath))
} }

View File

@ -8136,6 +8136,29 @@ components:
properties: properties:
filename: filename:
type: string type: string
deprecated: true
description: 'Deprecated in 6.1, use inputFilename instead'
inputFilename:
type: string
description: 'Uploaded/imported filename'
fileDownloadUrl:
type: string
description: "**PeerTube >= 6.1** If enabled by the admin, the video source file is kept on the server and can be downloaded by the owner"
resolution:
$ref: '#/components/schemas/VideoResolutionConstant'
description: "**PeerTube >= 6.1**"
size:
type: integer
description: "**PeerTube >= 6.1** Video file size in bytes"
fps:
type: number
description: "**PeerTube >= 6.1** Frames per second of the video file"
width:
type: number
description: "**PeerTube >= 6.1** Video stream width"
height:
type: number
description: "**PeerTube >= 6.1** Video stream height"
createdAt: createdAt:
type: string type: string
format: date-time format: date-time
@ -8792,6 +8815,11 @@ components:
properties: properties:
enabled: enabled:
type: boolean type: boolean
originalFile:
type: object
properties:
keep:
type: boolean
allowAdditionalExtensions: allowAdditionalExtensions:
type: boolean type: boolean
description: Allow your users to upload .mkv, .mov, .avi, .wmv, .flv, .f4v, .3g2, .3gp, .mts, m2ts, .mxf, .nut videos description: Allow your users to upload .mkv, .mov, .avi, .wmv, .flv, .f4v, .3g2, .3gp, .mts, m2ts, .mxf, .nut videos