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,
resolutions: {},
alwaysTranscodeOriginalResolution: null,
originalFile: {
keep: null
},
hls: {
enabled: null
},

View file

@ -39,7 +39,7 @@
<ng-container ngProjectAs="extra">
<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()">
<my-peertube-checkbox
@ -63,10 +63,21 @@
</ng-container>
</my-peertube-checkbox>
</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 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">
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">

View file

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

View file

@ -16,7 +16,7 @@
</button>
</div>
<div class="modal-body">
<div class="modal-body" [ngClass]="{ 'opacity-0': !loaded }">
<div class="alert alert-warning" *ngIf="isConfidentialVideo()" i18n>
The following link contains a private token and should not be shared with anyone.
</div>
@ -45,13 +45,30 @@
<!-- Video tab -->
<ng-container *ngIf="type === 'video'">
<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>
<div class="nav-content">
<my-input-text [show]="true" [readonly]="true" [withCopy]="true" [withToggle]="false" [value]="getLink()"></my-input-text>
</div>
<ng-template [ngTemplateOutlet]="rootNavContent"></ng-template>
</ng-template>
</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-container>
</div>
@ -60,47 +77,59 @@
<div class="advanced-filters" [ngbCollapse]="isAdvancedCustomizationCollapsed" [animation]="true">
<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>
<a ngbNavLink i18n>Format</a>
<ng-template ngbNavContent>
<div class="file-metadata">
<div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataFormat | keyvalue">
<span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
<span class="metadata-attribute-value">{{ item.value.value }}</span>
</div>
@for (item of videoFileMetadataFormat | keyvalue; track item) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem [disabled]="videoFileMetadataVideoStream === undefined">
<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]="videoFileMetadataVideoStream === undefined">
<a ngbNavLink i18n>Video stream</a>
<ng-container ngbNavItem [disabled]="videoFileMetadataAudioStream === undefined">
<a ngbNavLink i18n>Audio stream</a>
<ng-template ngbNavContent>
<div class="file-metadata">
<div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataAudioStream | 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-template ngbNavContent>
<div class="file-metadata">
@for (item of videoFileMetadataVideoStream | keyvalue; track item) {
<ng-template [ngTemplateOutlet]="metadataInfo" [ngTemplateOutletContext]="{ $implicit: item }"></ng-template>
}
</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>
</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">
<input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
<label i18n for="download-direct">Direct download</label>
@ -121,17 +150,13 @@
<ng-container *ngIf="isAdvancedCustomizationCollapsed">
<span class="chevron-down"></span>
<ng-container i18n>
Advanced
</ng-container>
<ng-container i18n>More information/options</ng-container>
</ng-container>
<ng-container *ngIf="!isAdvancedCustomizationCollapsed">
<span class="chevron-up"></span>
<ng-container i18n>
Simple
</ng-container>
<ng-container i18n>Less information/options</ng-container>
</ng-container>
</button>
</ng-container>

View file

@ -5,6 +5,13 @@
margin-top: 30px;
}
my-global-icon[iconName=shield] {
@include margin-left(10px);
width: 16px;
margin-top: -3px;
}
.advanced-filters-button {
display: flex;
justify-content: center;
@ -53,7 +60,7 @@
display: block;
margin-bottom: 12px;
.metadata-attribute-label {
> span:first-child {
@include padding-right(5px);
min-width: 142px;
@ -61,22 +68,4 @@
color: pvar(--greyForegroundColor);
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 { firstValueFrom } from 'rxjs'
import { tap } from 'rxjs/operators'
import { KeyValuePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
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 {
NgbCollapse,
NgbModal,
NgbModalRef,
NgbNav,
NgbNavContent,
NgbNavItem,
NgbNavLink,
NgbNavLinkBase,
NgbNavContent,
NgbNavOutlet,
NgbCollapse
NgbTooltip
} 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 { videoRequiresFileToken } from '@root-helpers/video'
import { objectKeysTyped, pick } from '@peertube/peertube-core-utils'
import { VideoCaption, VideoFile } from '@peertube/peertube-models'
import { mapValues } from 'lodash-es'
import { firstValueFrom, of } from 'rxjs'
import { tap } from 'rxjs/operators'
import { InputTextComponent } from '../shared-forms/input-text.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 { 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 { VideoService } from '../shared-main/video/video.service'
@ -49,7 +50,10 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } }
InputTextComponent,
NgbNavOutlet,
NgbCollapse,
KeyValuePipe
KeyValuePipe,
NgbTooltip,
NgTemplateOutlet,
NgClass
]
})
export class VideoDownloadComponent {
@ -59,7 +63,7 @@ export class VideoDownloadComponent {
downloadType: 'direct' | 'torrent' = 'direct'
resolutionId: number | string = -1
resolutionId: number | 'original' = -1
subtitleLanguageId: string
videoFileMetadataFormat: FileMetadata
@ -72,6 +76,10 @@ export class VideoDownloadComponent {
videoFileToken: string
originalVideoFile: VideoSource
loaded = false
private activeModal: NgbModalRef
private bytesPipe: BytesPipe
@ -83,6 +91,7 @@ export class VideoDownloadComponent {
constructor (
@Inject(LOCALE_ID) private localeId: string,
private modalService: NgbModal,
private authService: AuthService,
private videoService: VideoService,
private videoFileTokenService: VideoFileTokenService,
private hooks: HooksService
@ -110,7 +119,10 @@ export class VideoDownloadComponent {
}
show (video: VideoDetails, videoCaptions?: VideoCaption[]) {
this.loaded = false
this.videoFileToken = undefined
this.originalVideoFile = undefined
this.video = video
this.videoCaptions = videoCaptions
@ -125,16 +137,40 @@ export class VideoDownloadComponent {
this.subtitleLanguageId = this.videoCaptions[0].language.id
}
if (this.isConfidentialVideo()) {
this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword })
.subscribe(({ token }) => this.videoFileToken = token)
}
this.getOriginalVideoFileObs()
.subscribe(source => {
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.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 () {
this.video = undefined
this.videoCaptions = undefined
@ -152,28 +188,29 @@ export class VideoDownloadComponent {
: this.getVideoFileLink()
}
async onResolutionIdChange (resolutionId: number) {
async onResolutionIdChange (resolutionId: number | 'original') {
this.resolutionId = resolutionId
const videoFile = this.getVideoFile()
let metadata: VideoFileMetadata
if (!videoFile.metadata) {
if (!videoFile.metadataUrl) return
if (this.resolutionId === 'original') {
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.getMetadataFormat(videoFile.metadata.format)
: undefined
this.videoFileMetadataVideoStream = videoFile
? this.getMetadataStream(videoFile.metadata.streams, 'video')
: undefined
this.videoFileMetadataAudioStream = videoFile
? this.getMetadataStream(videoFile.metadata.streams, 'audio')
: undefined
this.videoFileMetadataFormat = this.getMetadataFormat(metadata.format)
this.videoFileMetadataVideoStream = this.getMetadataStream(metadata.streams, 'video')
this.videoFileMetadataAudioStream = this.getMetadataStream(metadata.streams, 'audio')
}
onSubtitleIdChange (subtitleId: string) {
@ -185,8 +222,10 @@ export class VideoDownloadComponent {
}
getVideoFile () {
if (this.resolutionId === 'original') return undefined
const file = this.getVideoFiles()
.find(f => f.resolution.id === this.resolutionId)
.find(f => f.resolution.id === this.resolutionId)
if (!file) {
logger.error(`Could not find file with resolution ${this.resolutionId}`)
@ -197,13 +236,17 @@ export class VideoDownloadComponent {
}
getVideoFileLink () {
const file = this.getVideoFile()
if (!file) return ''
const suffix = this.isConfidentialVideo()
const suffix = this.resolutionId === 'original' || this.isConfidentialVideo()
? '?videoFileToken=' + this.videoFileToken
: ''
if (this.resolutionId === 'original') {
return this.originalVideoFile.fileDownloadUrl + suffix
}
const file = this.getVideoFile()
if (!file) return ''
switch (this.downloadType) {
case 'direct':
return file.fileDownloadUrl + suffix
@ -219,7 +262,7 @@ export class VideoDownloadComponent {
getCaption () {
const caption = this.getCaptions()
.find(c => c.language.id === this.subtitleLanguageId)
.find(c => c.language.id === this.subtitleLanguageId)
if (!caption) {
logger.error(`Cannot find caption ${this.subtitleLanguageId}`)
@ -237,19 +280,15 @@ export class VideoDownloadComponent {
}
isConfidentialVideo () {
return videoRequiresFileToken(this.video)
return this.resolutionId === 'original' || videoRequiresFileToken(this.video)
}
switchToType (type: DownloadType) {
this.type = type
}
getFileMetadata () {
const file = this.getVideoFile()
if (!file) return undefined
return file.metadata
hasMetadata () {
return !!this.videoFileMetadataFormat
}
private getMetadataFormat (format: any) {
@ -282,7 +321,9 @@ export class VideoDownloadComponent {
profile: (value: string) => ({ label: $localize`Profile`, value }),
bit_rate: (value: number | string) => ({
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
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"
>
<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/'
web_videos: 'storage/web-videos/'
streaming_playlists: 'storage/streaming-playlists/'
original_video_files: 'storage/original-video-files/'
redundancy: 'storage/redundancy/'
logs: 'storage/logs/'
previews: 'storage/previews/'
@ -238,6 +239,12 @@ object_storage:
prefix: ''
base_url: ''
# Same settings but for original video files
original_video_files:
bucket_name: 'original-video-files'
prefix: ''
base_url: ''
log:
level: 'info' # 'debug' | 'info' | 'warn' | 'error'
@ -526,6 +533,11 @@ video_channels:
transcoding:
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_additional_extensions: true
@ -844,7 +856,7 @@ services:
# Cards configuration to format video in Twitter/X
# All other social media (Facebook, Mastodon, etc.) are supported out of the box
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
username: '@Chocobozzz'

View file

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

View file

@ -150,6 +150,7 @@ storage:
avatars: '/var/www/peertube/storage/avatars/'
web_videos: '/var/www/peertube/storage/web-videos/'
streaming_playlists: '/var/www/peertube/storage/streaming-playlists/'
original_video_files: '/var/www/peertube/storage/original-video-files/'
redundancy: '/var/www/peertube/storage/redundancy/'
logs: '/var/www/peertube/storage/logs/'
previews: '/var/www/peertube/storage/previews/'
@ -236,6 +237,12 @@ object_storage:
prefix: ''
base_url: ''
# Same settings but for original video files
original_video_files:
bucket_name: 'original-video-files'
prefix: ''
base_url: ''
log:
level: 'info' # 'debug' | 'info' | 'warn' | 'error'
@ -536,6 +543,11 @@ video_channels:
transcoding:
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_additional_extensions: true

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -117,6 +117,10 @@ export interface CustomConfig {
transcoding: {
enabled: boolean
originalFile: {
keep: boolean
}
allowAdditionalExtensions: 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 {
filename: string
inputFilename: string
resolution?: VideoConstant<number>
size?: number // Bytes
width?: number
height?: number
fileDownloadUrl: string
fps?: number
metadata?: VideoFileMetadata
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 () {
return this.setChannelSyncEnabled(true)
}
@ -234,13 +247,17 @@ export class ConfigCommand extends AbstractCommand {
webVideo?: boolean // default true
hls?: boolean // default true
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({
newConfig: {
transcoding: {
enabled: true,
originalFile: {
keep: keepOriginal
},
allowAudioFiles: true,
allowAdditionalExtensions: true,
@ -261,13 +278,17 @@ export class ConfigCommand extends AbstractCommand {
enableMinimumTranscoding (options: {
webVideo?: 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({
newConfig: {
transcoding: {
enabled: true,
originalFile: {
keep: keepOriginal
},
allowAudioFiles: true,
allowAdditionalExtensions: true,
@ -560,6 +581,9 @@ export class ConfigCommand extends AbstractCommand {
},
transcoding: {
enabled: true,
originalFile: {
keep: false
},
remoteRunners: {
enabled: false
},

View file

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

View file

@ -379,6 +379,7 @@ export class PeerTubeServer {
avatars: this.getDirectoryPath('avatars') + '/',
web_videos: this.getDirectoryPath('web-videos') + '/',
streaming_playlists: this.getDirectoryPath('streaming-playlists') + '/',
original_video_files: this.getDirectoryPath('original-video-files') + '/',
redundancy: this.getDirectoryPath('redundancy') + '/',
logs: this.getDirectoryPath('logs') + '/',
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 { 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'
export class ServersCommand extends AbstractCommand {
@ -84,6 +84,8 @@ export class ServersCommand extends AbstractCommand {
return files.length
}
// ---------------------------------------------------------------------------
buildWebVideoFilePath (fileUrl: string) {
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)))
}
// ---------------------------------------------------------------------------
getLogContent () {
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 { 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 {
@ -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 & {
userId: number
}) {

View file

@ -19,244 +19,7 @@ describe('Test config API validators', function () {
let server: PeerTubeServer
let userAccessToken: string
const 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
}
}
let updateParams: CustomConfig
// ---------------------------------------------------------------
@ -266,6 +29,7 @@ describe('Test config API validators', function () {
server = await createSingleServer(1)
await setAccessTokensToServers([ server ])
updateParams = await server.config.getCustomConfig()
const user = {
username: 'user1',

View file

@ -1,8 +1,9 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { HttpStatusCode, VideoSource } from '@peertube/peertube-models'
import {
PeerTubeServer,
cleanupTests,
createSingleServer,
PeerTubeServer,
makeRawRequest,
setAccessTokensToServers,
setDefaultVideoChannel,
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 () {
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.webVideos.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.allowReplay).to.be.false
@ -205,6 +206,7 @@ function checkUpdatedConfig (data: CustomConfig) {
expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.false
expect(data.transcoding.hls.enabled).to.be.false
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.allowReplay).to.be.true
@ -349,6 +351,9 @@ const newCustomConfig: CustomConfig = {
remoteRunners: {
enabled: true
},
originalFile: {
keep: true
},
allowAdditionalExtensions: true,
allowAudioFiles: true,
threads: 1,

View file

@ -1,14 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
import {
cleanupTests, getRedirectionUrl, makeActivityPubRawRequest,
makeRawRequest,
ObjectStorageCommand,
PeerTubeServer,
waitJobs
} from '@peertube/peertube-server-commands'
import { expect } from 'chai'
import { wait } from '@peertube/peertube-core-utils'
import {
AccountExportJSON, ActivityPubActor,
ActivityPubOrderedCollection,
@ -34,6 +26,15 @@ import {
VideoPlaylistType,
VideoPrivacy
} 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 {
checkExportFileExists,
checkFileExistsInZIP,
@ -44,8 +45,8 @@ import {
prepareImportExportTests,
regenerateExport
} from '@tests/shared/import-export.js'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import { wait } from '@peertube/peertube-core-utils'
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
import { expect } from 'chai'
function runTest (withObjectStorage: boolean) {
let server: PeerTubeServer
@ -69,10 +70,12 @@ function runTest (withObjectStorage: boolean) {
let noahExportId: number
let objectStorage: ObjectStorageCommand
before(async function () {
this.timeout(240000)
const objectStorage = withObjectStorage
objectStorage = withObjectStorage
? new ObjectStorageCommand()
: undefined;
@ -126,6 +129,10 @@ function runTest (withObjectStorage: boolean) {
expect(data[0].size).to.be.greaterThan(0)
expect(data[0].state.id).to.equal(UserExportState.COMPLETED)
expect(data[0].state.label).to.equal('Completed')
if (objectStorage) {
expectStartWith(await getRedirectionUrl(data[0].privateDownloadUrl), objectStorage.getMockUserExportBaseUrl())
}
}
await waitJobs([ server ])
@ -526,6 +533,14 @@ function runTest (withObjectStorage: boolean) {
for (const url of urls) {
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 */
import { MockSmtpServer } from '@tests/shared/mock-servers/index.js'
import {
cleanupTests, makeRawRequest,
ObjectStorageCommand,
PeerTubeServer, waitJobs
} from '@peertube/peertube-server-commands'
import {
HttpStatusCode,
LiveVideoLatencyMode,
@ -17,14 +11,20 @@ import {
VideoPrivacy,
VideoState
} from '@peertube/peertube-models'
import { prepareImportExportTests } from '@tests/shared/import-export.js'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import { writeFile } from 'fs/promises'
import { join } from 'path'
import { expect } from 'chai'
import { testImage, testAvatarSize } from '@tests/shared/checks.js'
import { completeVideoCheck } from '@tests/shared/videos.js'
import {
ObjectStorageCommand,
PeerTubeServer,
cleanupTests,
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 { completeVideoCheck } from '@tests/shared/videos.js'
import { expect } from 'chai'
import { join } from 'path'
function runTest (withObjectStorage: boolean) {
let server: PeerTubeServer
@ -115,17 +115,8 @@ function runTest (withObjectStorage: boolean) {
await server.userExports.request({ userId: noahId, withVideoFiles: true })
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')
await writeFile(archivePath, res.body)
await server.userExports.downloadLatestArchive({ userId: noahId, destination: archivePath })
})
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 })
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 () {
MockSmtpServer.Instance.kill()

View file

@ -1,24 +1,26 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { getAllFiles } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
import { expectStartWith } from '@tests/shared/checks.js'
import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models'
import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
import {
cleanupTests,
createMultipleServers,
doubleFollow,
makeGetRequest,
makeRawRequest,
ObjectStorageCommand,
PeerTubeServer,
cleanupTests,
createMultipleServers,
doubleFollow, makeGetRequest,
makeRawRequest,
setAccessTokensToServers,
setDefaultAccountAvatar,
setDefaultVideoChannel,
waitJobs
} 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 replaceDate: Date
@ -36,6 +38,7 @@ describe('Test a video file replacement', function () {
await setDefaultAccountAvatar(servers)
await servers[0].config.enableFileUpdate()
await servers[0].config.enableMinimumTranscoding()
userToken = await servers[0].users.generateUserAndToken('user1')
@ -44,30 +47,95 @@ describe('Test a video file replacement', function () {
})
describe('Getting latest video source', () => {
const fixture = 'video_short.webm'
const fixture1 = 'video_short.webm'
const fixture2 = 'video_short1.webm'
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)
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)
await waitJobs(servers)
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)
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)
await waitJobs(servers)
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)
for (const uuid of uuids) {
@ -75,6 +143,23 @@ describe('Test a video file replacement', function () {
}
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 () {
this.timeout(240000)
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
await waitJobs(servers)
await checkSourceFile({ server: servers[0], fsCount: 1, uuid, fixture: uploadFixture })
for (const server of servers) {
const video = await server.videos.get({ id: uuid })
expect(video.inputFileUpdatedAt).to.be.null
@ -151,9 +243,23 @@ describe('Test a video file replacement', function () {
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)
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) {
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 () {
{
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')
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')
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')
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 })
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)
})
@ -367,6 +474,9 @@ describe('Test a video file replacement', function () {
expect(files[0].resolution.id).to.equal(360)
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 () {
@ -374,16 +484,25 @@ describe('Test a video file replacement', function () {
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({
name: 'object storage with transcoding',
fixture: 'video_short_360p.mp4'
fixture: fixture1
})
uuid = videoUUID
await waitJobs(servers)
await checkSourceFile({
server: servers[0],
fixture: fixture1,
fsCount: 0,
uuid,
objectStorageBaseUrl: objectStorage?.getMockOriginalFileBaseUrl()
})
for (const server of servers) {
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 checkSourceFile({
server: servers[0],
fixture: fixture2,
fsCount: 0,
uuid,
objectStorageBaseUrl: objectStorage?.getMockOriginalFileBaseUrl()
})
for (const server of servers) {
const video = await server.videos.get({ id: uuid })

View file

@ -1,4 +1,5 @@
export * from './client-cli.js'
export * from './live-transcoding.js'
export * from './replace-file.js'
export * from './studio-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 */
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 { pathExists } from 'fs-extra/esm'
import { readdir } from 'fs/promises'
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 { checkWebTorrentWorks } from './webtorrent.js'
import { completeCheckHlsPlaylist } from './streaming-playlists.js'
import { checkWebTorrentWorks } from './webtorrent.js'
export async function completeWebVideoFilesCheck (options: {
server: PeerTubeServer
@ -369,3 +369,40 @@ export async function uploadRandomVideoOnServers (
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: {
enabled: CONFIG.TRANSCODING.ENABLED,
originalFile: {
keep: CONFIG.TRANSCODING.ORIGINAL_FILE.KEEP
},
remoteRunners: {
enabled: CONFIG.TRANSCODING.REMOTE_RUNNERS.ENABLED
},

View file

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

View file

@ -1,20 +1,20 @@
import express from 'express'
import { move } from 'fs-extra/esm'
import { buildAspectRatio } from '@peertube/peertube-core-utils'
import { VideoState } from '@peertube/peertube-models'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail.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 { 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 { buildNextVideoState } from '@server/lib/video-state.js'
import { openapiOperationDoc } from '@server/middlewares/doc.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 { VideoState } from '@peertube/peertube-models'
import express from 'express'
import { move } from 'fs-extra/esm'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import {
asyncMiddleware,
@ -23,7 +23,6 @@ import {
replaceVideoSourceResumableValidator,
videoSourceGetLatestValidator
} from '../../../middlewares/index.js'
import { buildAspectRatio } from '@peertube/peertube-core-utils'
const lTags = loggerTagsFactory('api', 'video')
@ -61,7 +60,7 @@ async function replaceVideoSourceResumable (req: express.Request, res: express.R
const videoPhysicalFile = res.locals.updateVideoFileResumable
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 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 })
const source = await VideoSourceModel.create({
filename: originalFilename,
videoId: video.id,
const source = await createVideoSource({
inputFilename: originalFilename,
inputProbe: res.locals.ffprobe,
inputPath: destination,
video,
createdAt: inputFileUpdatedAt
})
await regenerateMiniaturesIfNeeded(video)
await regenerateMiniaturesIfNeeded(video, res.locals.ffprobe)
await video.VideoChannel.setAsUpdated()
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 { LocalVideoCreator } from '@server/lib/local-video-creator.js'
import { Redis } from '@server/lib/redis.js'
import { setupUploadResumableRoutes, uploadx } from '@server/lib/uploadx.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js'
import { uuidToShort } from '@peertube/peertube-node-utils'
import { HttpStatusCode, ThumbnailType, VideoCreate } from '@peertube/peertube-models'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger.js'
import express from 'express'
import { VideoAuditView, auditLoggerFactory, getAuditIdFromRes } from '../../../helpers/audit-logger.js'
import { createReqFiles } from '../../../helpers/express-utils.js'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants.js'
@ -19,8 +21,6 @@ import {
videosAddResumableInitValidator,
videosAddResumableValidator
} 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 auditLogger = auditLoggerFactory('videos')
@ -134,7 +134,12 @@ async function addVideo (options: {
const localVideoCreator = new LocalVideoCreator({
lTags,
videoFilePath: videoPhysicalFile.path,
videoFile: {
path: videoPhysicalFile.path,
probe: res.locals.ffprobe
},
user: res.locals.oauth.token.User,
channel: res.locals.videoChannel,
@ -148,7 +153,7 @@ async function addVideo (options: {
...videoInfo,
duration: videoPhysicalFile.duration,
filename: videoPhysicalFile.originalname,
inputFilename: videoPhysicalFile.originalname,
state: buildNextVideoState(),
isLive: false
},

View file

@ -1,12 +1,14 @@
import cors from 'cors'
import express from 'express'
import { forceNumber } from '@peertube/peertube-core-utils'
import { FileStorage, HttpStatusCode, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { VideoTorrentsSimpleFileCache } from '@server/lib/files-cache/index.js'
import {
generateHLSFilePresignedUrl,
generateOriginalFilePresignedUrl,
generateUserExportPresignedUrl,
generateWebVideoPresignedUrl
} from '@server/lib/object-storage/index.js'
import { getFSUserExportFilePath } from '@server/lib/paths.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import {
@ -17,15 +19,16 @@ import {
MVideoFile,
MVideoFullLight
} from '@server/types/models/index.js'
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode, FileStorage, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { MVideoSource } from '@server/types/models/video/video-source.js'
import cors from 'cors'
import express from 'express'
import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants.js'
import {
asyncMiddleware, optionalAuthenticate,
originalVideoFileDownloadValidator,
userExportDownloadValidator,
videosDownloadValidator
} from '../middlewares/index.js'
import { getFSUserExportFilePath } from '@server/lib/paths.js'
const downloadRouter = express.Router()
@ -40,7 +43,7 @@ downloadRouter.use(
STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension',
optionalAuthenticate,
asyncMiddleware(videosDownloadValidator),
asyncMiddleware(downloadVideoFile)
asyncMiddleware(downloadWebVideoFile)
)
downloadRouter.use(
@ -51,11 +54,18 @@ downloadRouter.use(
)
downloadRouter.use(
STATIC_DOWNLOAD_PATHS.USER_EXPORT + ':filename',
STATIC_DOWNLOAD_PATHS.USER_EXPORTS + ':filename',
asyncMiddleware(userExportDownloadValidator), // Include JWT token authentication
asyncMiddleware(downloadUserExport)
)
downloadRouter.use(
STATIC_DOWNLOAD_PATHS.ORIGINAL_VIDEO_FILE + ':filename',
optionalAuthenticate,
asyncMiddleware(originalVideoFileDownloadValidator),
asyncMiddleware(downloadOriginalFile)
)
// ---------------------------------------------------------------------------
export {
@ -91,7 +101,7 @@ async function downloadTorrent (req: express.Request, res: express.Response) {
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 videoFile = getVideoFile(req, video.VideoFiles)
@ -184,6 +194,19 @@ function downloadUserExport (req: express.Request, res: express.Response) {
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[]) {
@ -262,3 +285,17 @@ async function redirectUserExportToObjectStorage (options: {
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(
[ STATIC_PATHS.PRIVATE_WEB_VIDEOS, STATIC_PATHS.LEGACY_PRIVATE_WEB_VIDEOS ],
...privateWebVideoStaticMiddlewares,
express.static(DIRECTORIES.VIDEOS.PRIVATE, { fallthrough: false }),
express.static(DIRECTORIES.WEB_VIDEOS.PRIVATE, { fallthrough: false }),
handleStaticError
)
staticRouter.use(
[ 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
)

View file

@ -303,34 +303,68 @@ function checkLiveConfig () {
}
function checkObjectStorageConfig () {
if (CONFIG.OBJECT_STORAGE.ENABLED === true) {
if (CONFIG.OBJECT_STORAGE.ENABLED !== true) return
if (!CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BUCKET_NAME) {
throw new Error('videos_bucket should be set when object storage support is enabled.')
if (!CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BUCKET_NAME) {
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('streaming_playlists_bucket should be set when object storage support is enabled.')
throw new Error(
'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 (
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
CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BUCKET_NAME === CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES.BUCKET_NAME &&
CONFIG.OBJECT_STORAGE.WEB_VIDEOS.PREFIX === CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES.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(
'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')) {
// 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).`)
if (
CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME === CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES.BUCKET_NAME &&
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 () {

View file

@ -32,8 +32,8 @@ function checkMissedConfig () {
'signup.enabled', 'signup.limit', 'signup.requires_approval', 'signup.requires_email_verification', 'signup.minimum_age',
'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
'redundancy.videos.strategies', 'redundancy.videos.check_interval',
'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions', 'transcoding.web_videos.enabled',
'transcoding.hls.enabled', 'transcoding.profile', 'transcoding.concurrency',
'transcoding.enabled', 'transcoding.original_file.keep', 'transcoding.threads', 'transcoding.allow_additional_extensions',
'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.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
'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.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.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',
'feeds.videos.count', 'feeds.comments.count',
'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')),
WEB_VIDEOS_DIR: buildPath(config.get<string>('storage.web_videos')),
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')),
THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')),
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'),
PREFIX: config.get<string>('object_storage.user_exports.prefix'),
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: {
@ -412,6 +418,9 @@ const CONFIG = {
},
TRANSCODING: {
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_AUDIO_FILES () { return config.get<boolean>('transcoding.allow_audio_files') },
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/',
VIDEOS: '/download/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 = {
THUMBNAILS: '/lazy-static/thumbnails/',
@ -981,11 +982,13 @@ const DIRECTORIES = {
PRIVATE: join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls', 'private')
},
VIDEOS: {
WEB_VIDEOS: {
PUBLIC: CONFIG.STORAGE.WEB_VIDEOS_DIR,
PRIVATE: join(CONFIG.STORAGE.WEB_VIDEOS_DIR, 'private')
},
ORIGINAL_VIDEOS: CONFIG.STORAGE.ORIGINAL_VIDEO_FILES_DIR,
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.PUBLIC))
tasks.push(ensureDir(DIRECTORIES.VIDEOS.PUBLIC))
tasks.push(ensureDir(DIRECTORIES.VIDEOS.PRIVATE))
tasks.push(ensureDir(DIRECTORIES.WEB_VIDEOS.PUBLIC))
tasks.push(ensureDir(DIRECTORIES.WEB_VIDEOS.PRIVATE))
// Resumable upload directory
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({
video: replayVideo,
videoFile: replayVideo.getMaxQualityFile(),
types: [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ]
types: [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ],
ffprobe: undefined
})
for (const thumbnail of thumbnails) {
@ -238,7 +239,7 @@ async function replaceLiveByReplay (options: {
}
// Regenerate the thumbnail & preview?
await regenerateMiniaturesIfNeeded(videoWithFiles)
await regenerateMiniaturesIfNeeded(videoWithFiles, undefined)
// We consider this is a new video
await moveToNextState({ video: videoWithFiles, isNewVideo: true })

View file

@ -1,5 +1,4 @@
import { buildAspectRatio } from '@peertube/peertube-core-utils'
import { ffprobePromise } from '@peertube/peertube-ffmpeg'
import {
LiveVideoCreate,
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 { VideoLiveModel } from '@server/models/video/video-live.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 { MChannel, MChannelAccountLight, MThumbnail, MUser, MVideoFile, MVideoFullLight } from '@server/types/models/index.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 { getLocalVideoActivityPubUrl } from './activitypub/url.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 { autoBlacklistVideoIfNeeded } from './video-blacklist.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 { VideoPathManager } from './video-path-manager.js'
import { setVideoTags } from './video.js'
@ -39,7 +37,7 @@ type VideoAttributes = Omit<VideoCreate, 'channelId'> & {
duration: number
isLive: boolean
state: VideoStateType
filename: string
inputFilename: string
}
type LiveAttributes = Pick<LiveVideoCreate, 'permanentLive' | 'latencyMode' | 'saveReplay' | 'replaySettings'> & {
@ -64,6 +62,8 @@ export class LocalVideoCreator {
private readonly lTags: LoggerTagsFn
private readonly videoFilePath: string | undefined
private readonly videoFileProbe: FfprobeData
private readonly videoAttributes: VideoAttributes
private readonly liveAttributes: LiveAttributes | undefined
@ -72,12 +72,15 @@ export class LocalVideoCreator {
private video: MVideoFullLight
private videoFile: MVideoFile
private ffprobe: Ffmpeg.FfprobeData
private videoPath: string
constructor (private readonly options: {
lTags: LoggerTagsFn
videoFilePath: string
videoFile: {
path: string
probe: FfprobeData
}
videoAttributes: VideoAttributes
liveAttributes: LiveAttributes
@ -93,7 +96,8 @@ export class LocalVideoCreator {
finalFallback: ChaptersOption | undefined
}
}) {
this.videoFilePath = options.videoFilePath
this.videoFilePath = options.videoFile?.path
this.videoFileProbe = options.videoFile?.probe
this.videoAttributes = options.videoAttributes
this.liveAttributes = options.liveAttributes
@ -112,11 +116,10 @@ export class LocalVideoCreator {
this.video.url = getLocalVideoActivityPubUrl(this.video)
if (this.videoFilePath) {
this.ffprobe = await ffprobePromise(this.videoFilePath)
this.videoFile = await buildNewFile({ path: this.videoFilePath, mode: 'web-video', ffprobe: this.ffprobe })
this.videoFile = await buildNewFile({ path: this.videoFilePath, mode: 'web-video', ffprobe: this.videoFileProbe })
const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(this.video, this.videoFile)
await move(this.videoFilePath, destination)
this.videoPath = VideoPathManager.Instance.getFSVideoFileOutputPath(this.video, this.videoFile)
await move(this.videoFilePath, this.videoPath)
this.video.aspectRatio = buildAspectRatio({ width: this.videoFile.width, height: this.videoFile.height })
}
@ -166,13 +169,6 @@ export class LocalVideoCreator {
transaction
})
if (this.videoAttributes.filename) {
await VideoSourceModel.create({
filename: this.videoAttributes.filename,
videoId: this.video.id
}, { transaction })
}
if (this.videoAttributes.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
await VideoPasswordModel.addPasswords(this.videoAttributes.videoPasswords, this.video.id, transaction)
}
@ -197,10 +193,11 @@ export class LocalVideoCreator {
videoLive.videoId = this.video.id
this.video.VideoLive = await videoLive.save({ transaction })
}
if (this.videoFile) {
transaction.afterCommit(() => {
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 {
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
await this.channel.setAsUpdated()
@ -248,7 +254,12 @@ export class LocalVideoCreator {
return [
...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 { 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)
}
function generateHLSObjectBaseStorageKey (playlist: MStreamingPlaylistVideo) {
export function generateHLSObjectBaseStorageKey (playlist: MStreamingPlaylistVideo) {
return join(playlist.getStringType(), playlist.Video.uuid)
}
function generateWebVideoObjectStorageKey (filename: string) {
export function generateWebVideoObjectStorageKey (filename: string) {
return filename
}
function generateUserExportObjectStorageKey (filename: string) {
export function generateOriginalVideoObjectStorageKey (filename: string) {
return filename
}
export {
generateHLSObjectStorageKey,
generateHLSObjectBaseStorageKey,
generateWebVideoObjectStorageKey,
generateUserExportObjectStorageKey
export function generateUserExportObjectStorageKey (filename: string) {
return filename
}

View file

@ -1,8 +1,14 @@
import { CONFIG } from '@server/initializers/config.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 { getHLSPublicFileUrl, getWebVideoPublicFileUrl } from './urls.js'
import { getObjectStoragePublicFileUrl } from './urls.js'
export async function generateWebVideoPresignedUrl (options: {
file: MVideoFile
@ -16,7 +22,7 @@ export async function generateWebVideoPresignedUrl (options: {
downloadFilename
})
return getWebVideoPublicFileUrl(url)
return getObjectStoragePublicFileUrl(url, CONFIG.OBJECT_STORAGE.WEB_VIDEOS)
}
export async function generateHLSFilePresignedUrl (options: {
@ -32,7 +38,7 @@ export async function generateHLSFilePresignedUrl (options: {
downloadFilename
})
return getHLSPublicFileUrl(url)
return getObjectStoragePublicFileUrl(url, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
}
export async function generateUserExportPresignedUrl (options: {
@ -47,7 +53,22 @@ export async function generateUserExportPresignedUrl (options: {
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 { MVideoUUID } from '@server/types/models/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)
}
// ---------------------------------------------------------------------------
function getWebVideoPublicFileUrl (fileUrl: string) {
const baseUrl = CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BASE_URL
if (!baseUrl) return fileUrl
return replaceByBaseUrl(fileUrl, baseUrl)
}
function getHLSPublicFileUrl (fileUrl: string) {
const baseUrl = CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BASE_URL
export function getObjectStoragePublicFileUrl (fileUrl: string, objectStorageConfig: { BASE_URL: string }) {
const baseUrl = objectStorageConfig.BASE_URL
if (!baseUrl) return fileUrl
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}`
}
function getWebVideoPrivateFileUrl (filename: string) {
export function getWebVideoPrivateFileUrl (filename: string) {
return WEBSERVER.URL + OBJECT_STORAGE_PROXY_PATHS.PRIVATE_WEB_VIDEOS + filename
}
// ---------------------------------------------------------------------------
export {
getInternalUrl,
getWebVideoPublicFileUrl,
getHLSPublicFileUrl,
getHLSPrivateFileUrl,
getWebVideoPrivateFileUrl,
replaceByBaseUrl
}
// ---------------------------------------------------------------------------
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 { CONFIG } from '@server/initializers/config.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 { VideoPathManager } from '../video-path-manager.js'
import { generateHLSObjectBaseStorageKey, generateHLSObjectStorageKey, generateWebVideoObjectStorageKey } from './keys.js'
import {
generateHLSObjectBaseStorageKey,
generateHLSObjectStorageKey,
generateOriginalVideoObjectStorageKey,
generateWebVideoObjectStorageKey
} from './keys.js'
import {
createObjectReadStream,
listKeysOfPrefix,
lTags,
listKeysOfPrefix,
makeAvailable,
removeObject,
removeObjectByFullKey,
@ -19,13 +25,13 @@ import {
updatePrefixACL
} from './shared/index.js'
function listHLSFileKeysOf (playlist: MStreamingPlaylistVideo) {
export function listHLSFileKeysOf (playlist: MStreamingPlaylistVideo) {
return listKeysOfPrefix(generateHLSObjectBaseStorageKey(playlist), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
}
// ---------------------------------------------------------------------------
function storeHLSFileFromFilename (playlist: MStreamingPlaylistVideo, filename: string) {
export function storeHLSFileFromFilename (playlist: MStreamingPlaylistVideo, filename: string) {
return storeObject({
inputPath: join(getHLSDirectory(playlist.Video), 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({
inputPath: 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({
content,
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({
inputPath: VideoPathManager.Instance.getFSVideoFileOutputPath(video, file),
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({
objectStorageKey: generateWebVideoObjectStorageKey(file.filename),
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({
prefix: generateHLSObjectBaseStorageKey(playlist),
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)
}
function removeHLSFileObjectStorageByFilename (playlist: MStreamingPlaylistVideo, filename: string) {
export function removeHLSFileObjectStorageByFilename (playlist: MStreamingPlaylistVideo, filename: string) {
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)
}
function removeHLSFileObjectStorageByFullKey (key: string) {
export function removeHLSFileObjectStorageByFullKey (key: string) {
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)
}
// ---------------------------------------------------------------------------
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)
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
}
async function makeWebVideoFileAvailable (filename: string, destination: string) {
export async function makeWebVideoFileAvailable (filename: string, destination: string) {
const key = generateWebVideoObjectStorageKey(filename)
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
rangeHeader: string
}) {
@ -153,7 +176,7 @@ function getWebVideoFileReadStream (options: {
})
}
function getHLSFileReadStream (options: {
export function getHLSFileReadStream (options: {
playlist: MStreamingPlaylistVideo
filename: string
rangeHeader: string
@ -169,29 +192,17 @@ function getHLSFileReadStream (options: {
})
}
// ---------------------------------------------------------------------------
export function getOriginalFileReadStream (options: {
keptOriginalFilename: string
rangeHeader: string
}) {
const { keptOriginalFilename, rangeHeader } = options
export {
listHLSFileKeysOf,
const key = generateOriginalVideoObjectStorageKey(keptOriginalFilename)
storeWebVideoFile,
storeHLSFileFromFilename,
storeHLSFileFromPath,
storeHLSFileFromContent,
updateWebVideoFileACL,
updateHLSFilesACL,
removeHLSObjectStorage,
removeHLSFileObjectStorageByFilename,
removeHLSFileObjectStorageByPath,
removeHLSFileObjectStorageByFullKey,
removeWebVideoObjectStorage,
makeWebVideoFileAvailable,
makeHLSFileAvailable,
getWebVideoFileReadStream,
getHLSFileReadStream
return createObjectReadStream({
key,
bucketInfo: CONFIG.OBJECT_STORAGE.ORIGINAL_VIDEO_FILES,
rangeHeader
})
}

View file

@ -100,7 +100,7 @@ function generateLocalVideoMiniature (options: {
video: MVideoThumbnail
videoFile: MVideoFile
types: ThumbnailType_Type[]
ffprobe?: FfprobeData
ffprobe: FfprobeData
}): Promise<MThumbnail[]> {
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[] = []
if (video.getMiniature().automaticallyGenerated === true) {
@ -237,6 +237,7 @@ async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles) {
const models = await generateLocalVideoMiniature({
video,
videoFile: video.getMaxQualityFile(),
ffprobe,
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 { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
@ -11,7 +12,6 @@ import {
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models/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 { buildOriginalFileResolution, computeResolutionsToTranscode } from '../../transcoding-resolutions.js'
import { AbstractJobBuilder } from './abstract-job-builder.js'
@ -60,11 +60,7 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
const fps = computeOutputFPS({ inputFPS, resolution: maxResolution })
const priority = await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
const deleteInputFileId = isAudioInput || maxResolution !== resolution
? videoFile.id
: null
const jobPayload = { video, resolution: maxResolution, fps, isNewVideo, priority, deleteInputFileId }
const jobPayload = { video, resolution: maxResolution, fps, isNewVideo, priority, deleteInputFileId: videoFile.id }
const mainRunnerJob = videoFile.isAudio()
? await new VODAudioMergeTranscodingJobHandler().create(jobPayload)

View file

@ -1,22 +1,22 @@
import { Job } from 'bullmq'
import { move, remove } from 'fs-extra/esm'
import { copyFile } from 'fs/promises'
import { basename, join } from 'path'
import { buildAspectRatio } from '@peertube/peertube-core-utils'
import { TranscodeVODOptionsType, getVideoStreamDuration } from '@peertube/peertube-ffmpeg'
import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent.js'
import { VideoModel } from '@server/models/video/video.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 { VideoFileModel } from '../../models/video/video-file.js'
import { JobQueue } from '../job-queue/index.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 { buildFFmpegVOD } from './shared/index.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.
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: {
video: MVideoFullLight
resolution: number
@ -162,7 +162,6 @@ export async function mergeAudioVideofile (options: {
try {
await buildFFmpegVOD(job).transcode(transcodeOptions)
await remove(audioInputPath)
await remove(tmpPreviewPath)
} catch (err) {
await remove(tmpPreviewPath)
@ -213,14 +212,16 @@ export async function onWebVideoFileTranscoding (options: {
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) {
await saveNewOriginalFileIfNeeded(video, deleteWebInputVideoFile)
await video.removeWebVideoFile(deleteWebInputVideoFile)
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)
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 { 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 { 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 {
MStreamingPlaylistFiles,
MThumbnail, MVideo, MVideoAP, MVideoCaption,
@ -12,23 +23,12 @@ import {
MVideoFullLight, MVideoLiveWithSetting,
MVideoPassword
} from '@server/types/models/index.js'
import { logger } from '@server/helpers/logger.js'
import { ActivityCreate, VideoExportJSON, VideoObject, VideoPrivacy, FileStorage } from '@peertube/peertube-models'
import { MVideoSource } from '@server/types/models/video/video-source.js'
import Bluebird from 'bluebird'
import { getHLSFileReadStream, getWebVideoFileReadStream } from '@server/lib/object-storage/videos.js'
import { createReadStream } from 'fs'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { extname, join } from 'path'
import { Readable } from 'stream'
import { getAudience, audiencify } from '@server/lib/activitypub/audience.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'
import { AbstractUserExporter, ExportResult } from './abstract-user-exporter.js'
export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
@ -89,7 +89,7 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
// Then fetch more attributes for AP serialization
const videoAP = await video.lightAPToFullAP(undefined)
const { relativePathsFromJSON, staticFiles } = this.exportVideoFiles({ video, captions })
const { relativePathsFromJSON, staticFiles } = await this.exportVideoFiles({ video, captions })
return {
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),
source: source
? { filename: source.filename }
: null,
source: this.exportVideoSourceJSON(source),
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>> {
@ -271,7 +287,7 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
// ---------------------------------------------------------------------------
private exportVideoFiles (options: {
private async exportVideoFiles (options: {
video: MVideoFullLight
captions: MVideoCaption[]
}) {
@ -284,15 +300,27 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
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) {
staticFiles.push({
archivePath: this.getArchiveVideoFilePath(video, videoFile),
createrReadStream: () => this.generateVideoFileReadStream(video, videoFile)
})
// Prefer using original file if possible
const file = source?.keptOriginalFilename
? source
: 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) {
@ -317,6 +345,16 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
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> {
if (videoFile.storage === FileStorage.FILE_SYSTEM) {
return createReadStream(VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile))
@ -329,8 +367,8 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
return stream
}
private getArchiveVideoFilePath (video: MVideo, videoFile: MVideoFile) {
return join('video-files', video.uuid + extname(videoFile.filename))
private getArchiveVideoFilePath (video: MVideo, file: { filename?: string, keptOriginalFilename?: string }) {
return join('video-files', video.uuid + extname(file.filename || file.keptOriginalFilename))
}
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 { buildUUID, getFileSize } from '@peertube/peertube-node-utils'
import { MChannelId, MVideoFullLight } from '@server/types/models/index.js'
import { ffprobePromise, getVideoStreamDuration } from '@peertube/peertube-ffmpeg'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { AbstractUserImporter } from './abstract-user-importer.js'
import { isUserQuotaValid } from '@server/lib/user.js'
import { LiveVideoLatencyMode, ThumbnailType, VideoExportJSON, VideoPrivacy, VideoState } from '@peertube/peertube-models'
import { buildUUID, getFileSize } from '@peertube/peertube-node-utils'
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 {
isPasswordValid,
isVideoCategoryValid,
@ -25,17 +22,21 @@ import {
isVideoSupportValid,
isVideoTagValid
} from '@server/helpers/custom-validators/videos.js'
import { isVideoChannelUsernameValid } from '@server/helpers/custom-validators/video-channels.js'
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
import { isArray, isBooleanValid, isUUIDValid } from '@server/helpers/custom-validators/misc.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { isVideoCaptionLanguageValid } from '@server/helpers/custom-validators/video-captions.js'
import { isLiveLatencyModeValid } from '@server/helpers/custom-validators/video-lives.js'
import { parse } from 'path'
import { isLocalVideoFileAccepted } from '@server/lib/moderation.js'
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.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 { 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')
@ -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.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
@ -149,6 +150,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
let duration = 0
let ffprobe: FfprobeData
if (videoFilePath) {
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`)
@ -156,7 +158,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
await this.checkVideoFileIsAcceptedOrThrow({ videoFilePath, size: videoSize, channel: videoChannel, videoImportData })
const ffprobe = await ffprobePromise(videoFilePath)
ffprobe = await ffprobePromise(videoFilePath)
duration = await getVideoStreamDuration(videoFilePath, ffprobe)
}
@ -176,7 +178,11 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
const localVideoCreator = new LocalVideoCreator({
lTags,
videoFilePath,
videoFile: videoFilePath
? { path: videoFilePath, probe: ffprobe }
: undefined,
user: this.user,
channel: videoChannel,
@ -206,7 +212,9 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
videoPasswords: videoImportData.passwords,
duration,
filename: videoImportData.source?.filename,
inputFilename: videoImportData.source?.inputFilename,
state: videoImportData.isLive
? VideoState.WAITING_FOR_LIVE
: buildNextVideoState()

View file

@ -48,7 +48,7 @@ export class UserExporter {
if (exportModel.storage === FileStorage.FILE_SYSTEM) {
output = createWriteStream(getFSUserExportFilePath(exportModel))
endPromise = new Promise<void>(res => output.on('close', () => res()))
endPromise = new Promise<string>(res => output.on('close', () => res('')))
} else {
output = new PassThrough()
endPromise = storeUserExportFile(output as PassThrough, exportModel)
@ -56,12 +56,16 @@ export class UserExporter {
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.size = exportModel.storage === FileStorage.FILE_SYSTEM
? await getFileSize(getFSUserExportFilePath(exportModel))
: await getUserExportFileObjectStorageSize(exportModel)
await saveInTransactionWithRetries(exportModel)
} 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 { 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 { storeOriginalVideoFile } from './object-storage/videos.js'
import { generateHLSVideoFilename, generateWebVideoFilename } from './paths.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
mode: 'web-video' | 'hls'
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()
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))
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)
try {
@ -109,7 +113,7 @@ async function removeAllWebVideoFiles (video: MVideoWithAllFiles) {
return video
}
async function removeWebVideoFile (video: MVideoWithAllFiles, fileToDeleteId: number) {
export async function removeWebVideoFile (video: MVideoWithAllFiles, fileToDeleteId: number) {
const files = video.VideoFiles
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)
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
? MIMETYPES.AUDIO.EXT_MIMETYPE['.m4a']
: MIMETYPES.VIDEO.EXT_MIMETYPE[extname]
@ -146,14 +150,84 @@ function getVideoFileMimeType (extname: string, isAudio: boolean) {
// ---------------------------------------------------------------------------
export {
buildNewFile,
export async function createVideoSource (options: {
inputFilename: string
inputProbe: FfprobeData
inputPath: string
video: MVideoId
createdAt?: Date
}) {
const { inputFilename, inputPath, inputProbe, video, createdAt } = options
removeHLSPlaylist,
removeHLSFile,
removeAllWebVideoFiles,
removeWebVideoFile,
const videoSource = new VideoSourceModel({
inputFilename,
videoId: video.id,
createdAt
})
buildFileMetadata,
getVideoFileMimeType
if (inputPath) {
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 { buildUUID } from '@peertube/peertube-node-utils'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { extractVideo } from '@server/helpers/video.js'
import { CONFIG } from '@server/initializers/config.js'
@ -13,7 +11,9 @@ import {
MVideoFileStreamingPlaylistVideo,
MVideoFileVideo
} 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 { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from './paths.js'
import { isVideoInPrivateDirectory } from './video-privacy.js'
@ -56,10 +56,14 @@ class VideoPathManager {
}
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>) {

View file

@ -101,10 +101,10 @@ async function moveWebVideoFileOnFS (type: MoveType, video: MVideo, file: MVideo
function getWebVideoDirectories (moveType: MoveType) {
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('transcoding.enabled').isBoolean(),
body('transcoding.originalFile.keep').isBoolean(),
body('transcoding.allowAdditionalExtensions').isBoolean(),
body('transcoding.threads').isInt(),
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 { 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 { VideoTokensManager } from '@server/lib/video-tokens-manager.js'
import { authenticatePromise } from '@server/middlewares/auth.js'
@ -20,10 +19,12 @@ import {
MVideoId,
MVideoImmutable,
MVideoThumbnail,
MVideoUUID,
MVideoWithRights
} 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 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)) {
res.fail({
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)
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
res: Response
paramId: string
@ -128,7 +129,7 @@ async function checkCanSeeVideo (options: {
throw new Error('Unknown video privacy when checking video right ' + video.url)
}
async function checkCanSeeUserAuthVideo (options: {
export async function checkCanSeeUserAuthVideo (options: {
req: Request
res: Response
video: MVideoId | MVideoWithRights
@ -174,7 +175,7 @@ async function checkCanSeeUserAuthVideo (options: {
return fail()
}
async function checkCanSeePasswordProtectedVideo (options: {
export async function checkCanSeePasswordProtectedVideo (options: {
req: Request
res: Response
video: MVideo
@ -215,13 +216,13 @@ async function checkCanSeePasswordProtectedVideo (options: {
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
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
? video
: 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
req: Request
res: Response
@ -241,23 +242,51 @@ async function checkCanAccessVideoStaticFiles (options: {
return checkCanSeeVideo(options)
}
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 }
return true
}
assignVideoTokenIfNeeded(req, res, video)
if (res.locals.videoFileToken) return true
if (!video.hasPrivateStaticPath()) return true
res.sendStatus(HttpStatusCode.FORBIDDEN_403)
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
if (onlyOwned && video.isOwned() === false) {
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) {
res.fail({
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
@ -296,16 +325,3 @@ async function checkUserQuota (user: MUserId, videoFileSize: number, res: Respon
return true
}
// ---------------------------------------------------------------------------
export {
doesVideoChannelOfAccountExist,
doesVideoExist,
doesVideoFileOfVideoExist,
checkCanAccessVideoStaticFiles,
checkUserCanManageVideo,
checkCanSeeVideo,
checkUserQuota
}

View file

@ -1,6 +1,6 @@
import express from 'express'
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'
export async function addDurationToVideoFileIfNeeded (options: {
@ -11,7 +11,7 @@ export async function addDurationToVideoFileIfNeeded (options: {
const { res, middlewareName, videoFile } = options
try {
if (!videoFile.duration) await addDurationToVideo(videoFile)
if (!videoFile.duration) await addDurationToVideo(res, videoFile)
} catch (err) {
logger.error('Invalid input file in ' + middlewareName, { err })
@ -29,8 +29,11 @@ export async function addDurationToVideoFileIfNeeded (options: {
// Private
// ---------------------------------------------------------------------------
async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
const duration = await getVideoStreamDuration(videoFile.path)
async function addDurationToVideo (res: express.Response, videoFile: { path: string, duration?: number }) {
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
// 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 { CONFIG } from '@server/initializers/config.js'
import { buildUploadXFile, safeUploadXCleanup } from '@server/lib/uploadx.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { MVideoFullLight } from '@server/types/models/index.js'
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
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'
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
// ---------------------------------------------------------------------------

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 { HttpStatusCode, ServerErrorCode, UserRight, VideoState } from '@peertube/peertube-models'
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 { ExpressPromiseHandler } from '@server/types/express-handler.js'
import { MUserAccountId, MVideoFullLight } from '@server/types/models/index.js'
import express from 'express'
import { ValidationChain, body, param, query } from 'express-validator'
import {
exists,
isBooleanValid,
@ -41,8 +41,7 @@ import { CONFIG } from '../../../initializers/config.js'
import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants.js'
import { VideoModel } from '../../../models/video/video.js'
import {
areValidationErrors,
checkCanAccessVideoStaticFiles,
areValidationErrors, checkCanAccessVideoStaticFiles,
checkCanSeeVideo,
checkUserCanManageVideo,
doesVideoChannelOfAccountExist,
@ -501,23 +500,19 @@ const commonVideosFiltersValidator = [
// ---------------------------------------------------------------------------
export {
videosAddLegacyValidator,
videosAddResumableValidator,
videosAddResumableInitValidator,
videosUpdateValidator,
videosGetValidator,
videoFileMetadataGetValidator,
videosDownloadValidator,
checkVideoFollowConstraints,
videosCustomGetValidator,
videosRemoveValidator,
getCommonVideoEditAttributes,
commonVideosFiltersValidator,
videosOverviewValidator
getCommonVideoEditAttributes,
videoFileMetadataGetValidator,
videosAddLegacyValidator,
videosAddResumableInitValidator,
videosAddResumableValidator,
videosCustomGetValidator,
videosDownloadValidator,
videosGetValidator,
videosOverviewValidator,
videosRemoveValidator,
videosUpdateValidator
}
// ---------------------------------------------------------------------------

View file

@ -62,6 +62,10 @@ export class UserExportModel extends SequelizeModel<UserExportModel> {
@Column
storage: FileStorageType
@AllowNull(true)
@Column
fileUrl: string
@ForeignKey(() => UserModel)
@Column
userId: number
@ -188,7 +192,7 @@ export class UserExportModel extends SequelizeModel<UserExportModel> {
getFileDownloadUrl () {
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 {
Video,
VideoAdditionalAttributes,
@ -12,6 +7,11 @@ import {
VideosCommonQueryAfterSanitize,
VideoStreamingPlaylist
} 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 { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, VIDEO_STATES } from '../../../initializers/constants.js'
import { MServer, MStreamingPlaylistRedundanciesOpt, MVideoFormattable, MVideoFormattableDetails } from '../../../types/models/index.js'
@ -211,9 +211,7 @@ export function videoFilesModelToFormattedJSON (
resolution: {
id: videoFile.resolution,
label: videoFile.resolution === 0
? 'Audio'
: `${videoFile.resolution}p`
label: getResolutionLabel(videoFile.resolution)
},
width: videoFile.width,
@ -259,6 +257,12 @@ export function getStateLabel (id: number) {
return VIDEO_STATES[id] || 'Unknown'
}
export function getResolutionLabel (resolution: number) {
if (resolution === 0) return 'Audio'
return `${resolution}p`
}
// ---------------------------------------------------------------------------
// 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 { extractVideo } from '@server/helpers/video.js'
import { CONFIG } from '@server/initializers/config.js'
import { buildRemoteUrl } from '@server/lib/activitypub/url.js'
import {
getHLSPrivateFileUrl,
getHLSPublicFileUrl,
getWebVideoPrivateFileUrl,
getWebVideoPublicFileUrl
getObjectStoragePublicFileUrl,
getWebVideoPrivateFileUrl
} from '@server/lib/object-storage/index.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 { MStreamingPlaylistVideo, MVideo, MVideoWithHost, isStreamingPlaylist } from '@server/types/models/index.js'
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 { VideoStreamingPlaylistModel } from './video-streaming-playlist.js'
import { VideoModel } from './video.js'
import { getVideoFileMimeType } from '@server/lib/video-file.js'
export enum ScopeNames {
WITH_VIDEO = 'WITH_VIDEO',
@ -534,10 +533,10 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> {
private getPublicObjectStorageUrl () {
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 { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
import { VideoSource } from '@peertube/peertube-models'
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
import { SequelizeModel, getSort } from '../shared/index.js'
import { getResolutionLabel } from './formatter/video-api-format.js'
import { VideoModel } from './video.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
@Table({
tableName: 'videoSource',
@ -12,6 +16,10 @@ import { VideoModel } from './video.js'
},
{
fields: [ { name: 'createdAt', order: 'DESC' } ]
},
{
fields: [ 'keptOriginalFilename' ],
unique: true
}
]
})
@ -24,7 +32,43 @@ export class VideoSourceModel extends SequelizeModel<VideoSourceModel> {
@AllowNull(false)
@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)
@Column
@ -39,16 +83,51 @@ export class VideoSourceModel extends SequelizeModel<VideoSourceModel> {
Video: Awaited<VideoModel>
static loadLatest (videoId: number, transaction?: Transaction) {
return VideoSourceModel.findOne({
return VideoSourceModel.findOne<MVideoSource>({
where: { videoId },
order: getSort('-createdAt'),
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 {
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()
}
}

View file

@ -6,7 +6,7 @@ import {
} from '@peertube/peertube-models'
import { sha1 } from '@peertube/peertube-node-utils'
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 { isVideoInPrivateDirectory } from '@server/lib/video-privacy.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 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 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 { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@peertube/peertube-ffmpeg'
import {
FileStorage,
ResultList,
ThumbnailType,
UserRight,
@ -13,7 +14,6 @@ import {
VideoPrivacy,
VideoRateType,
VideoState,
FileStorage,
VideoStreamingPlaylistType,
type VideoPrivacyType,
type VideoStateType
@ -25,6 +25,7 @@ import { LiveManager } from '@server/lib/live/live-manager.js'
import {
removeHLSFileObjectStorageByFilename,
removeHLSObjectStorage,
removeOriginalFileObjectStorage,
removeWebVideoObjectStorage
} from '@server/lib/object-storage/index.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 { getServerActor } from '@server/models/application/application.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 { remove } from 'fs-extra/esm'
import maxBy from 'lodash-es/maxBy.js'
@ -867,6 +869,12 @@ export class VideoModel extends SequelizeModel<VideoModel> {
for (const p of instance.VideoStreamingPlaylists) {
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
@ -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 () {
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 { RegisterServerAuthExternalOptions } from '@server/types/index.js'
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 { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element.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 {
MAccountDefault,
@ -127,6 +128,8 @@ declare module 'express' {
docUrl?: string
ffprobe?: FfprobeData
videoAPI?: MVideoFormattableDetails
videoAll?: MVideoFullLight
onlyImmutableVideo?: MVideoImmutable

View file

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

View file

@ -38,8 +38,8 @@ async function run () {
console.log('Detecting files to remove, it could take a while...')
toDelete = toDelete.concat(
await pruneDirectory(DIRECTORIES.VIDEOS.PUBLIC, doesWebVideoFileExist()),
await pruneDirectory(DIRECTORIES.VIDEOS.PRIVATE, doesWebVideoFileExist()),
await pruneDirectory(DIRECTORIES.WEB_VIDEOS.PUBLIC, doesWebVideoFileExist()),
await pruneDirectory(DIRECTORIES.WEB_VIDEOS.PRIVATE, doesWebVideoFileExist()),
await pruneDirectory(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, doesHLSPlaylistExist()),
await pruneDirectory(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, doesHLSPlaylistExist()),
@ -97,7 +97,7 @@ async function pruneDirectory (directory: string, existFun: ExistFun) {
function doesWebVideoFileExist () {
return (filePath: string) => {
// 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))
}

View file

@ -8136,6 +8136,29 @@ components:
properties:
filename:
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:
type: string
format: date-time
@ -8792,6 +8815,11 @@ components:
properties:
enabled:
type: boolean
originalFile:
type: object
properties:
keep:
type: boolean
allowAdditionalExtensions:
type: boolean
description: Allow your users to upload .mkv, .mov, .avi, .wmv, .flv, .f4v, .3g2, .3gp, .mts, m2ts, .mxf, .nut videos