<img src="https://david-dm.org/Chocobozzz/PeerTube/dev-status.svg?path=client" alt="devDependency Status" />
</a>
- <a href="https://www.browserstack.com/automate/public-build/VXBPc0szNjUvRUNsREJQRFF6RkEvSjJBclZ4VUJBUm1hcS9RZGpUbitRST0tLWFWbjNEdVN6eEZpYTk4dGVpMkVlQWc9PQ==--644e755052bf7fe2346eb6e868be8e706718a17c%">
- <img src='https://www.browserstack.com/automate/badge.svg?badge_key=VXBPc0szNjUvRUNsREJQRFF6RkEvSjJBclZ4VUJBUm1hcS9RZGpUbitRST0tLWFWbjNEdVN6eEZpYTk4dGVpMkVlQWc9PQ==--644e755052bf7fe2346eb6e868be8e706718a17c%'/>
+ <a href="https://www.browserstack.com/automate/public-build/cWJhRDFJbS9qeUhzYW04MnlIVjlQQ0x3aE5POXBaV1lycGo5VlQxK3JqZz0tLTNUWW5ySEVvS1N4UnBhYlhsdXVCeVE9PQ==--db09e291d36a582af8b2929d62a625ed660cdf1d">
+ <img src='https://www.browserstack.com/automate/badge.svg?badge_key=cWJhRDFJbS9qeUhzYW04MnlIVjlQQ0x3aE5POXBaV1lycGo5VlQxK3JqZz0tLTNUWW5ySEVvS1N4UnBhYlhsdXVCeVE9PQ==--db09e291d36a582af8b2929d62a625ed660cdf1d'/>
</a>
</p>
## Dependencies
* nginx
- * PostgreSQL
+ * **PostgreSQL >= 9.6**
* **Redis >= 2.8.18**
* **NodeJS >= 8.x**
* yarn
- * OpenSSL (cli)
* **FFmpeg >= 3.x**
## Run in production
- Avoid violating the privacy of others, disrupting our systems, destroying data, and/or harming user experience.
- Use only the Official Channels to discuss vulnerability information with us.
- Keep the details of any discovered vulnerabilities confidential until they are fixed, according to the Disclosure Terms in this policy.
-- Perform testing only on in-scope systems, and respect systems and activities which are out-of-scope.
+- Perform testing only on in-scope systems, and respect systems and activities which are out-of-scope. Systems currently considered in-scope are the official demonstration/test servers provided by the PeerTube development team.
- If a vulnerability provides unintended access to data: Limit the amount of data you access to the minimum required for effectively demonstrating a Proof of Concept; and cease testing and submit a report immediately if you encounter any user data during testing, such as Personally Identifiable Information (PII), Personal Healthcare Information (PHI), credit card data, or proprietary information.
- You should only interact with test accounts you own or with explicit permission from the account holder.
- Do not engage in extortion.
},
"assets": [
"src/assets/images",
- "src/manifest.json"
+ "src/manifest.webmanifest"
],
"styles": [
"src/sass/application.scss"
],
"assets": [
"src/assets/images",
- "src/manifest.json"
+ "src/manifest.webmanifest"
]
}
},
.then((texts: any) => texts.map(t => t.trim()))
}
- waitWatchVideoName (videoName: string, isSafari: boolean) {
- const elem = element(by.css('.video-info .video-info-name'))
+ waitWatchVideoName (videoName: string, isMobileDevice: boolean, isSafari: boolean) {
+ // On mobile we display the first node, on desktop the second
+ const index = isMobileDevice ? 0 : 1
+
+ const elem = element.all(by.css('.video-info .video-info-name')).get(index)
if (isSafari) return browser.sleep(5000)
let isSafari = false
beforeEach(async () => {
- browser.waitForAngularEnabled(false)
+ await browser.waitForAngularEnabled(false)
videoWatchPage = new VideoWatchPage()
pageUploadPage = new VideoUploadPage()
if (isMobileDevice || isSafari) videoNameToExcept = await videoWatchPage.clickOnFirstVideo()
else await videoWatchPage.clickOnVideo(videoName)
- return videoWatchPage.waitWatchVideoName(videoNameToExcept, isSafari)
+ return videoWatchPage.waitWatchVideoName(videoNameToExcept, isMobileDevice, isSafari)
})
it('Should play the video', async () => {
redirectTo: 'video-abuses/list',
pathMatch: 'full'
},
+ {
+ path: 'video-abuses',
+ redirectTo: 'video-abuses/list',
+ pathMatch: 'full'
+ },
+ {
+ path: 'video-blacklist',
+ redirectTo: 'video-blacklist/list',
+ pathMatch: 'full'
+ },
{
path: 'video-abuses/list',
component: VideoAbuseListComponent,
return
}
- const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this user?'), this.i18n('Delete'))
+ const message = this.i18n('If you remove this user, you will not be able to create another with the same username!')
+ const res = await this.confirmService.confirm(message, this.i18n('Delete'))
if (res === false) return
this.userService.removeUser(user).subscribe(
private routesPerRight = {
[UserRight.MANAGE_USERS]: '/admin/users',
[UserRight.MANAGE_SERVER_FOLLOW]: '/admin/friends',
- [UserRight.MANAGE_VIDEO_ABUSES]: '/admin/video-abuses',
- [UserRight.MANAGE_VIDEO_BLACKLIST]: '/admin/video-blacklist'
+ [UserRight.MANAGE_VIDEO_ABUSES]: '/admin/moderation/video-abuses',
+ [UserRight.MANAGE_VIDEO_BLACKLIST]: '/admin/moderation/video-blacklist',
+ [UserRight.MANAGE_JOBS]: '/admin/jobs',
+ [UserRight.MANAGE_CONFIGURATION]: '/admin/config'
}
constructor (
UserRight.MANAGE_USERS,
UserRight.MANAGE_SERVER_FOLLOW,
UserRight.MANAGE_VIDEO_ABUSES,
- UserRight.MANAGE_VIDEO_BLACKLIST
+ UserRight.MANAGE_VIDEO_BLACKLIST,
+ UserRight.MANAGE_JOBS,
+ UserRight.MANAGE_CONFIGURATION
]
for (const adminRight of adminRights) {
}
}
+ if (observables.length === 0) return of(videosOverviewResult)
+
return forkJoin(observables)
.pipe(
// Translate categories
<div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div>
<div
myInfiniteScroller
- [pageHeight]="pageHeight"
+ [pageHeight]="pageHeight" [firstLoadedPage]="firstLoadedPage"
(nearOfTop)="onNearOfTop()" (nearOfBottom)="onNearOfBottom()" (pageChanged)="onPageChanged($event)"
class="videos" #videosElement
>
- <div *ngFor="let videos of videoPages" class="videos-page">
- <my-video-miniature *ngFor="let video of videos" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType"></my-video-miniature>
+ <div *ngFor="let videos of videoPages; trackBy: pageByVideoId" class="videos-page">
+ <my-video-miniature *ngFor="let video of videos; trackBy: videoById" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType"></my-video-miniature>
</div>
</div>
</div>
videoHeight: number
videoPages: Video[][] = []
ownerDisplayType: OwnerDisplayType = 'account'
+ firstLoadedPage: number
protected baseVideoWidth = 215
- protected baseVideoHeight = 230
+ protected baseVideoHeight = 205
protected abstract notificationsService: NotificationsService
protected abstract authService: AuthService
if (this.resizeSubscription) this.resizeSubscription.unsubscribe()
}
+ pageByVideoId (index: number, page: Video[]) {
+ // Video are unique in all pages
+ return page[0].id
+ }
+
+ videoById (index: number, video: Video) {
+ return video.id
+ }
+
onNearOfTop () {
this.previousPage()
}
this.loadMoreVideos(this.pagination.currentPage)
}
- loadMoreVideos (page: number) {
+ loadMoreVideos (page: number, loadOnTop = false) {
+ this.adjustVideoPageHeight()
+
+ const currentY = window.scrollY
+
if (this.loadedPages[page] !== undefined) return
if (this.loadingPage[page] === true) return
({ videos, totalVideos }) => {
this.loadingPage[page] = false
+ if (this.firstLoadedPage === undefined || this.firstLoadedPage > page) this.firstLoadedPage = page
+
// Paging is too high, return to the first one
if (this.pagination.currentPage > 1 && totalVideos <= ((this.pagination.currentPage - 1) * this.pagination.itemsPerPage)) {
this.pagination.currentPage = 1
// Initialize infinite scroller now we loaded the first page
if (Object.keys(this.loadedPages).length === 1) {
// Wait elements creation
- setTimeout(() => this.infiniteScroller.initialize(), 500)
+ setTimeout(() => {
+ this.infiniteScroller.initialize()
+
+ // At our first load, we did not load the first page
+ // Load the previous page so the user can move on the top (and browser previous pages)
+ if (this.pagination.currentPage > 1) this.loadMoreVideos(this.pagination.currentPage - 1, true)
+ }, 500)
}
+
+ // Insert elements on the top but keep the scroll in the previous position
+ if (loadOnTop) setTimeout(() => { window.scrollTo(0, currentY + this.pageHeight) }, 0)
},
error => {
this.loadingPage[page] = false
const min = this.minPageLoaded()
if (min > 1) {
- this.loadMoreVideos(min - 1)
+ this.loadMoreVideos(min - 1, true)
}
}
this.videoPages = Object.values(this.loadedPages)
}
+ protected adjustVideoPageHeight () {
+ const numberOfPagesLoaded = Object.keys(this.loadedPages).length
+ if (!numberOfPagesLoaded) return
+
+ this.pageHeight = this.videosElement.nativeElement.offsetHeight / numberOfPagesLoaded
+ }
+
protected buildVideoHeight () {
// Same ratios than base width/height
return this.videosElement.nativeElement.offsetWidth * (this.baseVideoHeight / this.baseVideoWidth)
selector: '[myInfiniteScroller]'
})
export class InfiniteScrollerDirective implements OnInit, OnDestroy {
- private static PAGE_VIEW_TOP_MARGIN = 500
-
@Input() containerHeight: number
@Input() pageHeight: number
+ @Input() firstLoadedPage = 1
@Input() percentLimit = 70
@Input() autoInit = false
private scrollDownSub: Subscription
private scrollUpSub: Subscription
private pageChangeSub: Subscription
+ private middleScreen: number
constructor () {
this.decimalLimit = this.percentLimit / 100
}
initialize () {
+ this.middleScreen = window.innerHeight / 2
+
// Emit the last value
const throttleOptions = { leading: true, trailing: true }
}
private calculateCurrentPage (current: number) {
- return Math.max(1, Math.round((current + InfiniteScrollerDirective.PAGE_VIEW_TOP_MARGIN) / this.pageHeight))
+ const scrollY = current + this.middleScreen
+
+ const page = Math.max(1, Math.ceil(scrollY / this.pageHeight))
+
+ // Offset page
+ return page + (this.firstLoadedPage - 1)
}
}
<div class="video-miniature">
- <my-video-thumbnail [video]="video" [nsfw]="isVideoBlur()"></my-video-thumbnail>
+ <my-video-thumbnail [video]="video" [nsfw]="isVideoBlur"></my-video-thumbnail>
<div class="video-miniature-information">
<a
tabindex="-1"
class="video-miniature-name"
- [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur() }"
+ [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
>
{{ video.name }}
</a>
-import { Component, Input, OnInit } from '@angular/core'
+import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'
import { User } from '../users'
import { Video } from './video.model'
import { ServerService } from '@app/core'
@Component({
selector: 'my-video-miniature',
styleUrls: [ './video-miniature.component.scss' ],
- templateUrl: './video-miniature.component.html'
+ templateUrl: './video-miniature.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush
})
export class VideoMiniatureComponent implements OnInit {
@Input() user: User
@Input() video: Video
@Input() ownerDisplayType: OwnerDisplayType = 'account'
+ isVideoBlur: boolean
+
private ownerDisplayTypeChosen: 'account' | 'videoChannel'
constructor (private serverService: ServerService) { }
} else {
this.ownerDisplayTypeChosen = 'videoChannel'
}
- }
- isVideoBlur () {
- return this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())
+ this.isVideoBlur = this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())
}
displayOwnerAccount () {
<div class="peertube-select-container">
<select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId">
<option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
- <option [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
+ <option i18n [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
</select>
</div>
</div>
@include orange-button
}
}
+
+@media screen and (max-width: 450px) {
+ textarea, .submit-comment button {
+ font-size: 14px !important;
+ }
+}
\ No newline at end of file
.comment-account {
@include disable-default-a-behaviour;
+ word-break: break-all;
color: var(--mainForegroundColor);
font-weight: $font-bold;
}
img { margin-right: 10px; }
}
}
+
+@media screen and (max-width: 450px) {
+ .root-comment {
+ font-size: 14px;
+ }
+}
\ No newline at end of file
.view-replies {
margin-left: 46px;
}
-}
\ No newline at end of file
+}
+
+@media screen and (max-width: 450px) {
+ .view-replies {
+ font-size: 14px;
+ }
+}
flex-grow: 1;
// Set min width for flex item
min-width: 1px;
+ max-width: 100%;
.video-info-first-row {
display: flex;
margin: 20px 0 0 0;
.video-info {
+ padding: 0;
.video-info-first-row {
}
/deep/ .other-videos {
+ padding-left: 0 !important;
+
/deep/ .video-miniature {
flex-direction: column;
}
}
@media screen and (max-width: 450px) {
- .video-bottom .action-button .icon-text {
- display: none !important;
+ .video-bottom {
+ .action-button .icon-text {
+ display: none !important;
+ }
+
+ .video-info .video-info-first-row {
+ .video-info-name {
+ font-size: 18px;
+ }
+
+ .video-info-date-views {
+ font-size: 14px;
+ }
+
+ .video-actions-rates {
+ margin-top: 10px;
+ }
+ }
+
+ .video-info-description {
+ font-size: 14px !important;
+ }
}
}
-import { catchError, subscribeOn } from 'rxjs/operators'
+import { catchError } from 'rxjs/operators'
import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { RedirectService } from '@app/core/routing/redirect.service'
getRecommendations (recommendation: RecommendationInfo): Observable<Video[]> {
return this.fetchPage(1, recommendation)
.pipe(
- map(vids => {
- const otherVideos = vids.filter(v => v.uuid !== recommendation.uuid)
+ map(videos => {
+ const otherVideos = videos.filter(v => v.uuid !== recommendation.uuid)
return otherVideos.slice(0, this.pageSize)
})
)
import { Video } from '@app/shared/video/video.model'
import { RecommendationInfo } from '@app/shared/video/recommendation-info.model'
import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service'
-import { RecommendationService, UUID } from '@app/videos/recommendations/recommendations.service'
-import { map, switchMap, take } from 'rxjs/operators'
+import { RecommendationService } from '@app/videos/recommendations/recommendations.service'
+import { map, shareReplay, switchMap, take } from 'rxjs/operators'
/**
* This store is intended to provide data for the RecommendedVideosComponent.
@Inject(RecentVideosRecommendationService) private recommendations: RecommendationService
) {
this.recommendations$ = this.requestsForLoad$$.pipe(
- switchMap(requestedRecommendation => recommendations.getRecommendations(requestedRecommendation)
- .pipe(take(1))
- ))
+ switchMap(requestedRecommendation => {
+ return recommendations.getRecommendations(requestedRecommendation)
+ .pipe(take(1))
+ }),
+ shareReplay()
+ )
+
this.hasRecommendations$ = this.recommendations$.pipe(
map(otherVideos => otherVideos.length > 0)
)
<div class="section" *ngFor="let object of overview.tags">
<div class="section-title" i18n>
- <a routerLink="/search" [queryParams]="{ tagOneOf: [ object.tag ] }">{{ object.tag }}</a>
+ <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">{{ object.tag }}</a>
</div>
<my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature>
import { renderVideo } from './video-renderer'
import './settings-menu-button'
import { PeertubePluginOptions, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
-import { isMobile, videoFileMaxByResolution, videoFileMinByResolution, timeToInt } from './utils'
+import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils'
import * as CacheChunkStore from 'cache-chunk-store'
import { PeertubeChunkStore } from './peertube-chunk-store'
import {
this.videoCaptions = options.videoCaptions
this.savePlayerSrcFunction = this.player.src
- // Hack to "simulate" src link in video.js >= 6
- // Without this, we can't play the video after pausing it
- // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633
- this.player.src = () => true
-
this.playerElement = options.playerElement
if (this.autoplay === true) this.player.addClass('vjs-has-autoplay')
this.player.one('play', () => {
// Don't run immediately scheduler, wait some seconds the TCP connections are made
- this.runAutoQualitySchedulerTimer = setTimeout(() => {
- this.runAutoQualityScheduler()
- }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
+ this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
})
})
// Do not display error to user because we will have multiple fallback
this.disableErrorDisplay()
+ // Hack to "simulate" src link in video.js >= 6
+ // Without this, we can't play the video after pausing it
+ // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633
this.player.src = () => true
const oldPlaybackRate = this.player.playbackRate()
this.trigger('videoFileUpdate')
}
- addTorrent (
+ updateResolution (resolutionId: number, delay = 0) {
+ // Remember player state
+ const currentTime = this.player.currentTime()
+ const isPaused = this.player.paused()
+
+ // Remove poster to have black background
+ this.playerElement.poster = ''
+
+ // Hide bigPlayButton
+ if (!isPaused) {
+ this.player.bigPlayButton.hide()
+ }
+
+ const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId)
+ const options = {
+ forcePlay: false,
+ delay,
+ seek: currentTime + (delay / 1000)
+ }
+ this.updateVideoFile(newVideoFile, options)
+ }
+
+ flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
+ if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) {
+ if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy()
+
+ this.webtorrent.remove(videoFile.magnetUri)
+ console.log('Removed ' + videoFile.magnetUri)
+ }
+ }
+
+ isAutoResolutionOn () {
+ return this.autoResolution
+ }
+
+ enableAutoResolution () {
+ this.autoResolution = true
+ this.trigger('autoResolutionUpdate')
+ }
+
+ disableAutoResolution (forbid = false) {
+ if (forbid === true) this.forbidAutoResolution = true
+
+ this.autoResolution = false
+ this.trigger('autoResolutionUpdate')
+ }
+
+ isAutoResolutionForbidden () {
+ return this.forbidAutoResolution === true
+ }
+
+ getCurrentVideoFile () {
+ return this.currentVideoFile
+ }
+
+ getTorrent () {
+ return this.torrent
+ }
+
+ private addTorrent (
magnetOrTorrentUrl: string,
previousVideoFile: VideoFile,
options: {
if (oldTorrent) {
// Pause the old torrent
- oldTorrent.pause()
- // Pause does not remove actual peers (in particular the webseed peer)
- oldTorrent.removePeer(oldTorrent['ws'])
+ this.stopTorrent(oldTorrent)
// We use a fake renderer so we download correct pieces of the next file
- if (options.delay) {
- const fakeVideoElem = document.createElement('video')
- renderVideo(torrent.files[0], fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => {
- this.fakeRenderer = renderer
-
- if (err) console.error('Cannot render new torrent in fake video element.', err)
-
- // Load the future file at the correct time
- fakeVideoElem.currentTime = this.player.currentTime() + (options.delay / 2000)
- })
- }
+ if (options.delay) this.renderFileInFakeElement(torrent.files[ 0 ], options.delay)
}
// Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution)
this.addTorrentDelay = setTimeout(() => {
+ // We don't need the fake renderer anymore
this.destroyFakeRenderer()
const paused = this.player.paused()
this.flushVideoFile(previousVideoFile)
const renderVideoOptions = { autoplay: false, controls: true }
- renderVideo(torrent.files[0], this.playerElement, renderVideoOptions,(err, renderer) => {
+ renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => {
this.renderer = renderer
if (err) return this.fallbackToHttp(done)
if (err.message.indexOf('incorrect info hash') !== -1) {
console.error('Incorrect info hash detected, falling back to torrent file.')
const newOptions = { forcePlay: true, seek: options.seek }
- return this.addTorrent(this.torrent['xs'], previousVideoFile, newOptions, done)
+ return this.addTorrent(this.torrent[ 'xs' ], previousVideoFile, newOptions, done)
}
// Remote instance is down
})
}
- updateResolution (resolutionId: number, delay = 0) {
- // Remember player state
- const currentTime = this.player.currentTime()
- const isPaused = this.player.paused()
-
- // Remove poster to have black background
- this.playerElement.poster = ''
-
- // Hide bigPlayButton
- if (!isPaused) {
- this.player.bigPlayButton.hide()
- }
-
- const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId)
- const options = {
- forcePlay: false,
- delay,
- seek: currentTime + (delay / 1000)
- }
- this.updateVideoFile(newVideoFile, options)
- }
-
- flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
- if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) {
- if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy()
-
- this.webtorrent.remove(videoFile.magnetUri)
- console.log('Removed ' + videoFile.magnetUri)
- }
- }
-
- isAutoResolutionOn () {
- return this.autoResolution
- }
-
- enableAutoResolution () {
- this.autoResolution = true
- this.trigger('autoResolutionUpdate')
- }
-
- disableAutoResolution (forbid = false) {
- if (forbid === true) this.forbidAutoResolution = true
-
- this.autoResolution = false
- this.trigger('autoResolutionUpdate')
- }
-
- isAutoResolutionForbidden () {
- return this.forbidAutoResolution === true
- }
-
- getCurrentVideoFile () {
- return this.currentVideoFile
- }
-
- getTorrent () {
- return this.torrent
- }
-
private tryToPlay (done?: Function) {
if (!done) done = function () { /* empty */ }
if (this.autoplay === true) {
this.player.posterImage.hide()
- this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
- } else {
- // Don't try on iOS that does not support MediaSource
- if (this.isIOS()) {
- this.currentVideoFile = this.pickAverageVideoFile()
- return this.fallbackToHttp(undefined, false)
- }
+ return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
+ }
- // Proxy first play
- const oldPlay = this.player.play.bind(this.player)
- this.player.play = () => {
- this.player.addClass('vjs-has-big-play-button-clicked')
- this.player.play = oldPlay
+ // Don't try on iOS that does not support MediaSource
+ if (this.isIOS()) {
+ this.currentVideoFile = this.pickAverageVideoFile()
+ return this.fallbackToHttp(undefined, false)
+ }
- this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
- }
+ // Proxy first play
+ const oldPlay = this.player.play.bind(this.player)
+ this.player.play = () => {
+ this.player.addClass('vjs-has-big-play-button-clicked')
+ this.player.play = oldPlay
+
+ this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
}
}
return this.videoFiles[Math.floor(this.videoFiles.length / 2)]
}
+ private stopTorrent (torrent: WebTorrent.Torrent) {
+ torrent.pause()
+ // Pause does not remove actual peers (in particular the webseed peer)
+ torrent.removePeer(torrent[ 'ws' ])
+ }
+
+ private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) {
+ const fakeVideoElem = document.createElement('video')
+ renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => {
+ this.fakeRenderer = renderer
+
+ if (err) console.error('Cannot render new torrent in fake video element.', err)
+
+ // Load the future file at the correct time (in delay MS - 2 seconds)
+ fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000)
+ })
+ }
+
private destroyFakeRenderer () {
if (this.fakeRenderer) {
if (this.fakeRenderer.destroy) {
this.eventHandlers()
player.ready(() => {
- this.build()
- this.reset()
+ // Voodoo magic for IOS
+ setTimeout(() => {
+ this.build()
+ this.reset()
+ }, 0)
})
}
import { NgModuleRef, ApplicationRef } from '@angular/core'
import { createNewHosts } from '@angularclass/hmr'
+import { enableDebugTools } from '@angular/platform-browser'
export const hmrBootstrap = (module: any, bootstrap: () => Promise<NgModuleRef<any>>) => {
let ngModule: NgModuleRef<any>
module.hot.accept()
bootstrap()
- .then(mod => ngModule = mod)
+ .then(mod => {
+ ngModule = mod
+
+ const applicationRef = ngModule.injector.get(ApplicationRef);
+ const componentRef = applicationRef.components[ 0 ]
+ // allows to run `ng.profiler.timeChangeDetection();`
+ enableDebugTools(componentRef)
+ })
module.hot.dispose(() => {
const appRef: ApplicationRef = ngModule.injector.get(ApplicationRef)
const elements = appRef.components.map(c => c.location.nativeElement)
<meta name="theme-color" content="#fff" />
<!-- Web Manifest file -->
- <link rel="manifest" href="/manifest.json">
+ <link rel="manifest" href="/manifest.webmanifest">
<!-- /!\ The following comment is used by the server to prerender some tags /!\ -->
"src": "/client/assets/images/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
- },
+ },
{
"src": "/client/assets/images/icons/icon-144x144.png",
"sizes": "144x144",
@import '~video.js/dist/video-js.css';
$assets-path: '../assets/';
-@import './player/player';
+@import './player/index';
@import './loading-bar';
@import './primeng-custom';
-ms-hyphens: auto;
-moz-hyphens: auto;
hyphens: auto;
- text-align: justify;
}
@mixin peertube-input-text($width) {
width: 37px;
margin-right: 1px;
+ cursor: pointer;
.vjs-icon-placeholder {
transition: transform 0.2s ease;
}
}
- .vjs-playback-rate {
- display: none;
- }
-
.vjs-peertube {
padding: 0 !important;
@import '~videojs-dock/dist/videojs-dock.css';
$assets-path: '../../assets/';
-@import '../../sass/player/player';
+@import '../../sass/player/index';
[hidden] {
display: none !important;
# Once you have defined your strategies, choose which instances you want to cache in admin -> manage follows -> following
redundancy:
videos:
-# -
-# size: '10GB'
-# strategy: 'most-views' # Cache videos that have the most views
+ check_interval: '1 hour' # How often you want to check new videos to cache
+ strategies:
+# -
+# size: '10GB'
+# strategy: 'most-views' # Cache videos that have the most views
+# -
+# size: '10GB'
+# strategy: 'trending' # Cache trending videos
+# -
+# size: '10GB'
+# strategy: 'recently-added' # Cache recently added videos
+# minViews: 10 # Having at least x views
cache:
previews:
# Robot.txt rules. To disallow robots to crawl your instance and disallow indexation of your site, add '/' to "Disallow:'
robots: |
User-agent: *
- Disallow: ''
+ Disallow:
# Security.txt rules. To discourage researchers from testing your instance and disable security.txt integration, set this to an empty string.
securitytxt:
"# If you would like to report a security issue\n# you may report it to:\nContact: https://github.com/Chocobozzz/PeerTube/blob/develop/SECURITY.md\nContact: mailto:"
# Once you have defined your strategies, choose which instances you want to cache in admin -> manage follows -> following
redundancy:
videos:
-# -
-# size: '10GB'
-# strategy: 'most-views' # Cache videos that have the most views
+ check_interval: '1 hour' # How often you want to check new videos to cache
+ strategies:
+# -
+# size: '10GB'
+# strategy: 'most-views' # Cache videos that have the most views
+# -
+# size: '10GB'
+# strategy: 'trending' # Cache trending videos
+# -
+# size: '10GB'
+# strategy: 'recently-added' # Cache recently added videos
+# minViews: 10 # Having at least x views
###############################################################################
#
# Robot.txt rules. To disallow robots to crawl your instance and disallow indexation of your site, add '/' to "Disallow:'
robots: |
User-agent: *
- Disallow: ''
+ Disallow:
# Security.txt rules. To discourage researchers from testing your instance and disable security.txt integration, set this to an empty string.
securitytxt:
"# If you would like to report a security issue\n# you may report it to:\nContact: https://github.com/Chocobozzz/PeerTube/blob/develop/SECURITY.md\nContact: mailto:"
redundancy:
videos:
- -
- size: '100KB'
- strategy: 'most-views'
+ check_interval: '5 seconds'
+ strategies:
+ -
+ size: '10MB'
+ strategy: 'most-views'
+ -
+ size: '10MB'
+ strategy: 'trending'
+ -
+ size: '10MB'
+ strategy: 'recently-added'
+ minViews: 1
cache:
previews:
},
"lint-staged": {
"*.scss": [
- "sass-lint -c .sass-lint.yml",
+ "sass-lint -c client/.sass-lint.yml",
"git add"
]
},
"jsonld-signatures": "https://github.com/Chocobozzz/jsonld-signatures#rsa2017",
"lodash": "^4.17.10",
"magnet-uri": "^5.1.4",
+ "memoizee": "^0.4.14",
"morgan": "^1.5.3",
"multer": "^1.1.0",
"netrc-parser": "^3.1.6",
"@types/lodash": "^4.14.64",
"@types/magnet-uri": "^5.1.1",
"@types/maildev": "^0.0.1",
+ "@types/memoizee": "^0.4.2",
"@types/mkdirp": "^0.5.1",
"@types/mocha": "^5.0.0",
"@types/morgan": "^1.7.32",
set -eu
-for i in $(seq 1 6); do
- dbname="peertube_test$i"
+recreateDB () {
+ dbname="peertube_test$1"
dropdb --if-exists "$dbname"
- rm -rf "./test$i"
- rm -f "./config/local-test.json"
- rm -f "./config/local-test-$i.json"
+
createdb -O peertube "$dbname"
- psql -c "CREATE EXTENSION pg_trgm;" "$dbname"
- psql -c "CREATE EXTENSION unaccent;" "$dbname"
- redis-cli KEYS "bull-localhost:900$i*" | grep -v empty | xargs --no-run-if-empty redis-cli DEL
+ psql -c "CREATE EXTENSION pg_trgm;" "$dbname" &
+ psql -c "CREATE EXTENSION unaccent;" "$dbname" &
+}
+
+removeFiles () {
+ rm -rf "./test$1" "./config/local-test.json" "./config/local-test-$1.json"
+}
+
+dropRedis () {
+ redis-cli KEYS "bull-localhost:900$1*" | grep -v empty | xargs --no-run-if-empty redis-cli DEL
+}
+
+for i in $(seq 1 6); do
+ recreateDB "$i" &
+ dropRedis "$i" &
+ removeFiles "$i" &
done
+
+wait
async function run () {
await initDatabaseModels(true)
- const video = await VideoModel.loadByUUID(program['video'])
+ const video = await VideoModel.loadByUUIDWithFile(program['video'])
if (!video) throw new Error('Video not found.')
if (video.isOwned() === false) throw new Error('Cannot import files of a non owned video.')
async function run () {
await initDatabaseModels(true)
- const video = await VideoModel.loadByUUID(program['video'])
+ const video = await VideoModel.loadByUUIDWithFile(program['video'])
if (!video) throw new Error('Video not found.')
const dataInput = {
const uuid = getUUIDFromFilename(file)
let video: VideoModel
- if (uuid) video = await VideoModel.loadByUUID(uuid)
+ if (uuid) video = await VideoModel.loadByUUIDWithFile(uuid)
if (!uuid || !video) toDelete.push(join(directory, file))
}
import { buildAnnounceWithVideoAudience } from '../../lib/activitypub/send'
import { audiencify, getAudience } from '../../lib/activitypub/audience'
import { buildCreateActivity } from '../../lib/activitypub/send/send-create'
-import { asyncMiddleware, executeIfActivityPub, localAccountValidator, localVideoChannelValidator } from '../../middlewares'
+import {
+ asyncMiddleware,
+ executeIfActivityPub,
+ localAccountValidator,
+ localVideoChannelValidator,
+ videosCustomGetValidator
+} from '../../middlewares'
import { videosGetValidator, videosShareValidator } from '../../middlewares/validators'
import { videoCommentGetValidator } from '../../middlewares/validators/video-comments'
import { AccountModel } from '../../models/account/account'
executeIfActivityPub(asyncMiddleware(videoController))
)
activityPubClientRouter.get('/videos/watch/:id/announces',
- executeIfActivityPub(asyncMiddleware(videosGetValidator)),
+ executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video'))),
executeIfActivityPub(asyncMiddleware(videoAnnouncesController))
)
activityPubClientRouter.get('/videos/watch/:id/announces/:accountId',
executeIfActivityPub(asyncMiddleware(videoAnnounceController))
)
activityPubClientRouter.get('/videos/watch/:id/likes',
- executeIfActivityPub(asyncMiddleware(videosGetValidator)),
+ executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video'))),
executeIfActivityPub(asyncMiddleware(videoLikesController))
)
activityPubClientRouter.get('/videos/watch/:id/dislikes',
- executeIfActivityPub(asyncMiddleware(videosGetValidator)),
+ executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video'))),
executeIfActivityPub(asyncMiddleware(videoDislikesController))
)
activityPubClientRouter.get('/videos/watch/:id/comments',
- executeIfActivityPub(asyncMiddleware(videosGetValidator)),
+ executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video'))),
executeIfActivityPub(asyncMiddleware(videoCommentsController))
)
activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId',
import { activityPubValidator } from '../../middlewares/validators/activitypub/activity'
import { VideoChannelModel } from '../../models/video/video-channel'
import { AccountModel } from '../../models/account/account'
+import { queue } from 'async'
+import { ActorModel } from '../../models/activitypub/actor'
const inboxRouter = express.Router()
signatureValidator,
asyncMiddleware(checkSignature),
asyncMiddleware(activityPubValidator),
- asyncMiddleware(inboxController)
+ inboxController
)
inboxRouter.post('/accounts/:name/inbox',
asyncMiddleware(checkSignature),
asyncMiddleware(localAccountValidator),
asyncMiddleware(activityPubValidator),
- asyncMiddleware(inboxController)
+ inboxController
)
inboxRouter.post('/video-channels/:name/inbox',
signatureValidator,
asyncMiddleware(checkSignature),
asyncMiddleware(localVideoChannelValidator),
asyncMiddleware(activityPubValidator),
- asyncMiddleware(inboxController)
+ inboxController
)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
-async function inboxController (req: express.Request, res: express.Response, next: express.NextFunction) {
+const inboxQueue = queue<{ activities: Activity[], signatureActor?: ActorModel, inboxActor?: ActorModel }, Error>((task, cb) => {
+ processActivities(task.activities, task.signatureActor, task.inboxActor)
+ .then(() => cb())
+})
+
+function inboxController (req: express.Request, res: express.Response, next: express.NextFunction) {
const rootActivity: RootActivity = req.body
let activities: Activity[] = []
logger.info('Receiving inbox requests for %d activities by %s.', activities.length, res.locals.signature.actor.url)
- await processActivities(activities, res.locals.signature.actor, accountOrChannel ? accountOrChannel.Actor : undefined)
+ inboxQueue.push({
+ activities,
+ signatureActor: res.locals.signature.actor,
+ inboxActor: accountOrChannel ? accountOrChannel.Actor : undefined
+ })
- res.status(204).end()
+ return res.status(204).end()
}
import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares'
import { customConfigUpdateValidator } from '../../middlewares/validators/config'
import { ClientHtml } from '../../lib/client-html'
-import { auditLoggerFactory, CustomConfigAuditView } from '../../helpers/audit-logger'
+import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger'
import { remove, writeJSON } from 'fs-extra'
const packageJSON = require('../../../../package.json')
async function deleteCustomConfig (req: express.Request, res: express.Response, next: express.NextFunction) {
await remove(CONFIG.CUSTOM_FILE)
- auditLogger.delete(
- res.locals.oauth.token.User.Account.Actor.getIdentifier(),
- new CustomConfigAuditView(customConfig())
- )
+ auditLogger.delete(getAuditIdFromRes(res), new CustomConfigAuditView(customConfig()))
reloadConfig()
ClientHtml.invalidCache()
const data = customConfig()
auditLogger.update(
- res.locals.oauth.token.User.Account.Actor.getIdentifier(),
+ getAuditIdFromRes(res),
new CustomConfigAuditView(data),
oldCustomConfigAuditKeys
)
import { asyncMiddleware } from '../../middlewares'
import { TagModel } from '../../models/video/tag'
import { VideosOverview } from '../../../shared/models/overviews'
-import { OVERVIEWS, ROUTE_CACHE_LIFETIME } from '../../initializers'
+import { MEMOIZE_TTL, OVERVIEWS, ROUTE_CACHE_LIFETIME } from '../../initializers'
import { cacheRoute } from '../../middlewares/cache'
+import * as memoizee from 'memoizee'
const overviewsRouter = express.Router()
// ---------------------------------------------------------------------------
+const buildSamples = memoizee(async function () {
+ const [ categories, channels, tags ] = await Promise.all([
+ VideoModel.getRandomFieldSamples('category', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT),
+ VideoModel.getRandomFieldSamples('channelId', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD ,OVERVIEWS.VIDEOS.SAMPLES_COUNT),
+ TagModel.getRandomSamples(OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT)
+ ])
+
+ return { categories, channels, tags }
+}, { maxAge: MEMOIZE_TTL.OVERVIEWS_SAMPLE })
+
// This endpoint could be quite long, but we cache it
async function getVideosOverview (req: express.Request, res: express.Response) {
const attributes = await buildSamples()
+
+ const [ categories, channels, tags ] = await Promise.all([
+ Promise.all(attributes.categories.map(c => getVideosByCategory(c, res))),
+ Promise.all(attributes.channels.map(c => getVideosByChannel(c, res))),
+ Promise.all(attributes.tags.map(t => getVideosByTag(t, res)))
+ ])
+
const result: VideosOverview = {
- categories: await Promise.all(attributes.categories.map(c => getVideosByCategory(c, res))),
- channels: await Promise.all(attributes.channels.map(c => getVideosByChannel(c, res))),
- tags: await Promise.all(attributes.tags.map(t => getVideosByTag(t, res)))
+ categories,
+ channels,
+ tags
}
// Cleanup our object
return res.json(result)
}
-async function buildSamples () {
- const [ categories, channels, tags ] = await Promise.all([
- VideoModel.getRandomFieldSamples('category', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT),
- VideoModel.getRandomFieldSamples('channelId', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD ,OVERVIEWS.VIDEOS.SAMPLES_COUNT),
- TagModel.getRandomSamples(OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT)
- ])
-
- return { categories, channels, tags }
-}
-
async function getVideosByTag (tag: string, res: express.Response) {
const videos = await getVideos(res, { tagsOneOf: [ tag ] })
res: express.Response,
where: { videoChannelId?: number, tagsOneOf?: string[], categoryOneOf?: number[] }
) {
- const { data } = await VideoModel.listForApi(Object.assign({
+ const query = Object.assign({
start: 0,
count: 10,
sort: '-createdAt',
includeLocalVideos: true,
nsfw: buildNSFWFilter(res),
withFiles: false
- }, where))
+ }, where)
+
+ const { data } = await VideoModel.listForApi(query, false)
return data.map(d => d.toFormattedJSON())
}
const isURISearch = search.startsWith('http://') || search.startsWith('https://')
const parts = search.split('@')
+
+ // Handle strings like @toto@example.com
+ if (parts.length === 3 && parts[0].length === 0) parts.shift()
const isWebfingerSearch = parts.length === 2 && parts.every(p => p.indexOf(' ') === -1)
if (isURISearch || isWebfingerSearch) return searchVideoChannelURI(search, isWebfingerSearch, res)
if (isUserAbleToSearchRemoteURI(res)) {
try {
- const actor = await getOrCreateActorAndServerAndModel(uri, true, true)
+ const actor = await getOrCreateActorAndServerAndModel(uri, 'all', true, true)
videoChannel = actor.VideoChannel
} catch (err) {
logger.info('Cannot search remote video channel %s.', uri, { err })
refreshVideo: false
}
- const result = await getOrCreateVideoAndAccountAndChannel(url, syncParam)
+ const result = await getOrCreateVideoAndAccountAndChannel({ videoObject: url, syncParam })
video = result ? result.video : undefined
} catch (err) {
logger.info('Cannot search remote video %s.', url, { err })
import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
import { VideoModel } from '../../../models/video/video'
import { VideoCommentModel } from '../../../models/video/video-comment'
+import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
+import { CONFIG, ROUTE_CACHE_LIFETIME } from '../../../initializers/constants'
+import { cacheRoute } from '../../../middlewares/cache'
const statsRouter = express.Router()
statsRouter.get('/stats',
+ asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.STATS)),
asyncMiddleware(getStats)
)
const { totalUsers } = await UserModel.getStats()
const { totalInstanceFollowers, totalInstanceFollowing } = await ActorFollowModel.getStats()
+ const videosRedundancyStats = await Promise.all(
+ CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.map(r => {
+ return VideoRedundancyModel.getStats(r.strategy)
+ .then(stats => Object.assign(stats, { strategy: r.strategy, totalSize: r.size }))
+ })
+ )
+
const data: ServerStats = {
totalLocalVideos,
totalLocalVideoViews,
totalVideoComments,
totalUsers,
totalInstanceFollowers,
- totalInstanceFollowing
+ totalInstanceFollowing,
+ videosRedundancy: videosRedundancyStats
}
return res.json(data).end()
usersUpdateValidator
} from '../../../middlewares'
import {
- usersAskResetPasswordValidator, usersBlockingValidator, usersResetPasswordValidator,
- usersAskSendVerifyEmailValidator, usersVerifyEmailValidator
+ usersAskResetPasswordValidator,
+ usersAskSendVerifyEmailValidator,
+ usersBlockingValidator,
+ usersResetPasswordValidator,
+ usersVerifyEmailValidator
} from '../../../middlewares/validators'
import { UserModel } from '../../../models/account/user'
import { OAuthTokenModel } from '../../../models/oauth/oauth-token'
-import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
+import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
import { meRouter } from './me'
+import { deleteUserToken } from '../../../lib/oauth-model'
const auditLogger = auditLoggerFactory('users')
const { user, account } = await createUserAccountAndChannel(userToCreate)
- auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new UserAuditView(user.toFormattedJSON()))
+ auditLogger.create(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()))
logger.info('User %s with its channel and account created.', body.username)
return res.json({
await user.destroy()
- auditLogger.delete(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new UserAuditView(user.toFormattedJSON()))
+ auditLogger.delete(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()))
return res.sendStatus(204)
}
const user = await userToUpdate.save()
// Destroy user token to refresh rights
- if (roleChanged) {
- await OAuthTokenModel.deleteUserToken(userToUpdate.id)
- }
+ if (roleChanged) await deleteUserToken(userToUpdate.id)
- auditLogger.update(
- res.locals.oauth.token.User.Account.Actor.getIdentifier(),
- new UserAuditView(user.toFormattedJSON()),
- oldUserAuditView
- )
+ auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
// Don't need to send this update to followers, these attributes are not propagated
user.blockedReason = reason || null
await sequelizeTypescript.transaction(async t => {
- await OAuthTokenModel.deleteUserToken(user.id, t)
+ await deleteUserToken(user.id, t)
await user.save({ transaction: t })
})
await Emailer.Instance.addUserBlockJob(user, block, reason)
- auditLogger.update(
- res.locals.oauth.token.User.Account.Actor.getIdentifier(),
- new UserAuditView(user.toFormattedJSON()),
- oldUserAuditView
- )
+ auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
}
import { CONFIG, IMAGE_MIMETYPE_EXT, sequelizeTypescript } from '../../../initializers'
import { sendUpdateActor } from '../../../lib/activitypub/send'
import {
- asyncMiddleware, asyncRetryTransactionMiddleware,
+ asyncMiddleware,
+ asyncRetryTransactionMiddleware,
authenticate,
commonVideosFiltersValidator,
paginationValidator,
usersVideoRatingValidator
} from '../../../middlewares'
import {
+ areSubscriptionsExistValidator,
deleteMeValidator,
userSubscriptionsSortValidator,
videoImportsSortValidator,
- videosSortValidator,
- areSubscriptionsExistValidator
+ videosSortValidator
} from '../../../middlewares/validators'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
import { UserModel } from '../../../models/account/user'
import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model'
import { updateAvatarValidator } from '../../../middlewares/validators/avatar'
import { updateActorAvatarFile } from '../../../lib/avatar'
-import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
+import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
import { VideoImportModel } from '../../../models/video/video-import'
import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
import { JobQueue } from '../../../lib/job-queue'
import { logger } from '../../../helpers/logger'
+import { AccountModel } from '../../../models/account/account'
const auditLogger = auditLoggerFactory('users-me')
}
async function getUserVideoRating (req: express.Request, res: express.Response, next: express.NextFunction) {
- const videoId = +req.params.videoId
+ const videoId = res.locals.video.id
const accountId = +res.locals.oauth.token.User.Account.id
const ratingObj = await AccountVideoRateModel.load(accountId, videoId, null)
await user.destroy()
- auditLogger.delete(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new UserAuditView(user.toFormattedJSON()))
+ auditLogger.delete(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()))
return res.sendStatus(204)
}
if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo
await sequelizeTypescript.transaction(async t => {
+ const userAccount = await AccountModel.load(user.Account.id)
+
await user.save({ transaction: t })
- if (body.displayName !== undefined) user.Account.name = body.displayName
- if (body.description !== undefined) user.Account.description = body.description
- await user.Account.save({ transaction: t })
+ if (body.displayName !== undefined) userAccount.name = body.displayName
+ if (body.description !== undefined) userAccount.description = body.description
+ await userAccount.save({ transaction: t })
- await sendUpdateActor(user.Account, t)
+ await sendUpdateActor(userAccount, t)
- auditLogger.update(
- res.locals.oauth.token.User.Account.Actor.getIdentifier(),
- new UserAuditView(user.toFormattedJSON()),
- oldUserAuditView
- )
+ auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
})
return res.sendStatus(204)
const avatarPhysicalFile = req.files[ 'avatarfile' ][ 0 ]
const user: UserModel = res.locals.oauth.token.user
const oldUserAuditView = new UserAuditView(user.toFormattedJSON())
- const account = user.Account
- const avatar = await updateActorAvatarFile(avatarPhysicalFile, account.Actor, account)
+ const userAccount = await AccountModel.load(user.Account.id)
- auditLogger.update(
- res.locals.oauth.token.User.Account.Actor.getIdentifier(),
- new UserAuditView(user.toFormattedJSON()),
- oldUserAuditView
- )
+ const avatar = await updateActorAvatarFile(avatarPhysicalFile, userAccount)
+
+ auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
return res.json({ avatar: avatar.toFormattedJSON() })
}
import { VideoModel } from '../../models/video/video'
import { updateAvatarValidator } from '../../middlewares/validators/avatar'
import { updateActorAvatarFile } from '../../lib/avatar'
-import { auditLoggerFactory, VideoChannelAuditView } from '../../helpers/audit-logger'
+import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger'
import { resetSequelizeInstance } from '../../helpers/database-utils'
+import { UserModel } from '../../models/account/user'
const auditLogger = auditLoggerFactory('channels')
const reqAvatarFile = createReqFiles([ 'avatarfile' ], IMAGE_MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.AVATARS_DIR })
// Check the rights
asyncMiddleware(videoChannelsUpdateValidator),
updateAvatarValidator,
- asyncMiddleware(updateVideoChannelAvatar)
+ asyncRetryTransactionMiddleware(updateVideoChannelAvatar)
)
videoChannelRouter.put('/:nameWithHost',
const videoChannel = res.locals.videoChannel as VideoChannelModel
const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
- const avatar = await updateActorAvatarFile(avatarPhysicalFile, videoChannel.Actor, videoChannel)
+ const avatar = await updateActorAvatarFile(avatarPhysicalFile, videoChannel)
- auditLogger.update(
- res.locals.oauth.token.User.Account.Actor.getIdentifier(),
- new VideoChannelAuditView(videoChannel.toFormattedJSON()),
- oldVideoChannelAuditKeys
- )
+ auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
return res
.json({
async function addVideoChannel (req: express.Request, res: express.Response) {
const videoChannelInfo: VideoChannelCreate = req.body
- const account: AccountModel = res.locals.oauth.token.User.Account
const videoChannelCreated: VideoChannelModel = await sequelizeTypescript.transaction(async t => {
+ const account = await AccountModel.load((res.locals.oauth.token.User as UserModel).Account.id, t)
+
return createVideoChannel(videoChannelInfo, account, t)
})
setAsyncActorKeys(videoChannelCreated.Actor)
.catch(err => logger.error('Cannot set async actor keys for account %s.', videoChannelCreated.Actor.uuid, { err }))
- auditLogger.create(
- res.locals.oauth.token.User.Account.Actor.getIdentifier(),
- new VideoChannelAuditView(videoChannelCreated.toFormattedJSON())
- )
+ auditLogger.create(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannelCreated.toFormattedJSON()))
logger.info('Video channel with uuid %s created.', videoChannelCreated.Actor.uuid)
return res.json({
await sendUpdateActor(videoChannelInstanceUpdated, t)
auditLogger.update(
- res.locals.oauth.token.User.Account.Actor.getIdentifier(),
+ getAuditIdFromRes(res),
new VideoChannelAuditView(videoChannelInstanceUpdated.toFormattedJSON()),
oldVideoChannelAuditKeys
)
await sequelizeTypescript.transaction(async t => {
await videoChannelInstance.destroy({ transaction: t })
- auditLogger.delete(
- res.locals.oauth.token.User.Account.Actor.getIdentifier(),
- new VideoChannelAuditView(videoChannelInstance.toFormattedJSON())
- )
+ auditLogger.delete(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannelInstance.toFormattedJSON()))
logger.info('Video channel with name %s and uuid %s deleted.', videoChannelInstance.name, videoChannelInstance.Actor.uuid)
})
import { VideoModel } from '../../../models/video/video'
import { VideoAbuseModel } from '../../../models/video/video-abuse'
import { auditLoggerFactory, VideoAbuseAuditView } from '../../../helpers/audit-logger'
+import { UserModel } from '../../../models/account/user'
const auditLogger = auditLoggerFactory('abuse')
const abuseVideoRouter = express.Router()
async function reportVideoAbuse (req: express.Request, res: express.Response) {
const videoInstance = res.locals.video as VideoModel
- const reporterAccount = res.locals.oauth.token.User.Account as AccountModel
const body: VideoAbuseCreate = req.body
- const abuseToCreate = {
- reporterAccountId: reporterAccount.id,
- reason: body.reason,
- videoId: videoInstance.id,
- state: VideoAbuseState.PENDING
- }
-
const videoAbuse: VideoAbuseModel = await sequelizeTypescript.transaction(async t => {
+ const reporterAccount = await AccountModel.load((res.locals.oauth.token.User as UserModel).Account.id, t)
+
+ const abuseToCreate = {
+ reporterAccountId: reporterAccount.id,
+ reason: body.reason,
+ videoId: videoInstance.id,
+ state: VideoAbuseState.PENDING
+ }
+
const videoAbuseInstance = await VideoAbuseModel.create(abuseToCreate, { transaction: t })
videoAbuseInstance.Video = videoInstance
videoAbuseInstance.Account = reporterAccount
})
logger.info('Abuse report for video %s created.', videoInstance.name)
- return res.json({
- videoAbuse: videoAbuse.toFormattedJSON()
- }).end()
+
+ return res.json({ videoAbuse: videoAbuse.toFormattedJSON() }).end()
}
} from '../../../middlewares/validators/video-comments'
import { VideoModel } from '../../../models/video/video'
import { VideoCommentModel } from '../../../models/video/video-comment'
-import { auditLoggerFactory, CommentAuditView } from '../../../helpers/audit-logger'
+import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
+import { AccountModel } from '../../../models/account/account'
+import { UserModel } from '../../../models/account/user'
const auditLogger = auditLoggerFactory('comments')
const videoCommentRouter = express.Router()
let resultList: ResultList<VideoCommentModel>
if (video.commentsEnabled === true) {
- resultList = await VideoCommentModel.listThreadCommentsForApi(res.locals.video.id, res.locals.videoCommentThread.id)
+ resultList = await VideoCommentModel.listThreadCommentsForApi(video.id, res.locals.videoCommentThread.id)
} else {
resultList = {
total: 0,
const videoCommentInfo: VideoCommentCreate = req.body
const comment = await sequelizeTypescript.transaction(async t => {
+ const account = await AccountModel.load((res.locals.oauth.token.User as UserModel).Account.id, t)
+
return createVideoComment({
text: videoCommentInfo.text,
inReplyToComment: null,
video: res.locals.video,
- account: res.locals.oauth.token.User.Account
+ account
}, t)
})
- auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new CommentAuditView(comment.toFormattedJSON()))
+ auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
return res.json({
comment: comment.toFormattedJSON()
const videoCommentInfo: VideoCommentCreate = req.body
const comment = await sequelizeTypescript.transaction(async t => {
+ const account = await AccountModel.load((res.locals.oauth.token.User as UserModel).Account.id, t)
+
return createVideoComment({
text: videoCommentInfo.text,
inReplyToComment: res.locals.videoComment,
video: res.locals.video,
- account: res.locals.oauth.token.User.Account
+ account
}, t)
})
- auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new CommentAuditView(comment.toFormattedJSON()))
+ auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
- return res.json({
- comment: comment.toFormattedJSON()
- }).end()
+ return res.json({ comment: comment.toFormattedJSON() }).end()
}
async function removeVideoComment (req: express.Request, res: express.Response) {
})
auditLogger.delete(
- res.locals.oauth.token.User.Account.Actor.getIdentifier(),
+ getAuditIdFromRes(res),
new CommentAuditView(videoCommentInstance.toFormattedJSON())
)
logger.info('Video comment %d deleted.', videoCommentInstance.id)
import * as express from 'express'
import * as magnetUtil from 'magnet-uri'
import 'multer'
-import { auditLoggerFactory, VideoImportAuditView } from '../../../helpers/audit-logger'
+import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
import {
CONFIG,
}
await JobQueue.Instance.createJob({ type: 'video-import', payload })
- auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new VideoImportAuditView(videoImport.toFormattedJSON()))
+ auditLogger.create(getAuditIdFromRes(res), new VideoImportAuditView(videoImport.toFormattedJSON()))
return res.json(videoImport.toFormattedJSON()).end()
}
}
await JobQueue.Instance.createJob({ type: 'video-import', payload })
- auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new VideoImportAuditView(videoImport.toFormattedJSON()))
+ auditLogger.create(getAuditIdFromRes(res), new VideoImportAuditView(videoImport.toFormattedJSON()))
return res.json(videoImport.toFormattedJSON()).end()
}
import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
import { processImage } from '../../../helpers/image-utils'
import { logger } from '../../../helpers/logger'
-import { auditLoggerFactory, VideoAuditView } from '../../../helpers/audit-logger'
+import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
import {
CONFIG,
await federateVideoIfNeeded(video, true, t)
- auditLogger.create(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
+ auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid)
return videoCreated
await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)
auditLogger.update(
- res.locals.oauth.token.User.Account.Actor.getIdentifier(),
+ getAuditIdFromRes(res),
new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()),
oldVideoAuditView
)
Redis.Instance.setIPVideoView(ip, videoInstance.uuid)
])
- const serverAccount = await getServerActor()
+ const serverActor = await getServerActor()
- await sendCreateView(serverAccount, videoInstance, undefined)
+ await sendCreateView(serverActor, videoInstance, undefined)
return res.status(204).end()
}
await videoInstance.destroy({ transaction: t })
})
- auditLogger.delete(res.locals.oauth.token.User.Account.Actor.getIdentifier(), new VideoAuditView(videoInstance.toFormattedDetailsJSON()))
+ auditLogger.delete(getAuditIdFromRes(res), new VideoAuditView(videoInstance.toFormattedDetailsJSON()))
logger.info('Video with name %s and uuid %s deleted.', videoInstance.name, videoInstance.uuid)
return res.type('json').status(204).end()
import { getFormattedObjects } from '../../../helpers/utils'
import { changeVideoChannelShare } from '../../../lib/activitypub'
import { sendUpdateVideo } from '../../../lib/activitypub/send'
+import { UserModel } from '../../../models/account/user'
const ownershipVideoRouter = express.Router()
async function giveVideoOwnership (req: express.Request, res: express.Response) {
const videoInstance = res.locals.video as VideoModel
- const initiatorAccount = res.locals.oauth.token.User.Account as AccountModel
+ const initiatorAccountId = (res.locals.oauth.token.User as UserModel).Account.id
const nextOwner = res.locals.nextOwner as AccountModel
await sequelizeTypescript.transaction(t => {
return VideoChangeOwnershipModel.findOrCreate({
where: {
- initiatorAccountId: initiatorAccount.id,
+ initiatorAccountId,
nextOwnerAccountId: nextOwner.id,
videoId: videoInstance.id,
status: VideoChangeOwnershipStatus.WAITING
},
defaults: {
- initiatorAccountId: initiatorAccount.id,
+ initiatorAccountId,
nextOwnerAccountId: nextOwner.id,
videoId: videoInstance.id,
status: VideoChangeOwnershipStatus.WAITING
},
transaction: t
})
-
})
logger.info('Ownership change for video %s created.', videoInstance.name)
}
async function listVideoOwnership (req: express.Request, res: express.Response) {
- const currentAccount = res.locals.oauth.token.User.Account as AccountModel
+ const currentAccountId = (res.locals.oauth.token.User as UserModel).Account.id
+
const resultList = await VideoChangeOwnershipModel.listForApi(
- currentAccount.id,
+ currentAccountId,
req.query.start || 0,
req.query.count || 10,
req.query.sort || 'createdAt'
const body: UserVideoRateUpdate = req.body
const rateType = body.rating
const videoInstance: VideoModel = res.locals.video
- const accountInstance: AccountModel = res.locals.oauth.token.User.Account
await sequelizeTypescript.transaction(async t => {
const sequelizeOptions = { transaction: t }
+
+ const accountInstance = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
const previousRate = await AccountVideoRateModel.load(accountInstance.id, videoInstance.id, t)
let likesToIncrement = 0
else if (previousRate.type === VIDEO_RATE_TYPES.DISLIKE) dislikesToIncrement--
if (rateType === 'none') { // Destroy previous rate
- await previousRate.destroy({ transaction: t })
+ await previousRate.destroy(sequelizeOptions)
} else { // Update previous rate
previousRate.type = rateType
- await previousRate.save({ transaction: t })
+ await previousRate.save(sequelizeOptions)
}
} else if (rateType !== 'none') { // There was not a previous rate, insert a new one if there is a rate
const query = {
await videoInstance.increment(incrementQuery, sequelizeOptions)
await sendVideoRateChange(accountInstance, videoInstance, likesToIncrement, dislikesToIncrement, t)
- })
- logger.info('Account video rate for video %s of account %s updated.', videoInstance.name, accountInstance.name)
+ logger.info('Account video rate for video %s of account %s updated.', videoInstance.name, accountInstance.name)
+ })
return res.type('json').status(204).end()
}
// Static HTML/CSS/JS client files
const staticClientFiles = [
- 'manifest.json',
+ 'manifest.webmanifest',
'ngsw-worker.js',
'ngsw.json'
]
--- /dev/null
+import { ActorModel } from '../models/activitypub/actor'
+
+type ActorFetchByUrlType = 'all' | 'actor-and-association-ids'
+function fetchActorByUrl (url: string, fetchType: ActorFetchByUrlType) {
+ if (fetchType === 'all') return ActorModel.loadByUrlAndPopulateAccountAndChannel(url)
+
+ if (fetchType === 'actor-and-association-ids') return ActorModel.loadByUrl(url)
+}
+
+export {
+ ActorFetchByUrlType,
+ fetchActorByUrl
+}
import * as path from 'path'
+import * as express from 'express'
import { diff } from 'deep-object-diff'
import { chain } from 'lodash'
import * as flatten from 'flat'
import { VideoDetails, User, VideoChannel, VideoAbuse, VideoImport } from '../../shared'
import { VideoComment } from '../../shared/models/videos/video-comment.model'
import { CustomConfig } from '../../shared/models/server/custom-config.model'
+import { UserModel } from '../models/account/user'
+
+function getAuditIdFromRes (res: express.Response) {
+ return (res.locals.oauth.token.User as UserModel).username
+}
enum AUDIT_TYPE {
CREATE = 'create',
}
export {
+ getAuditIdFromRes,
+
auditLoggerFactory,
VideoImportAuditView,
VideoChannelAuditView,
videoChangeOwnership: VideoChangeOwnershipModel,
res: Response
): boolean {
- if (videoChangeOwnership.NextOwner.userId === user.Account.userId) {
+ if (videoChangeOwnership.NextOwner.userId === user.id) {
return true
}
import { VideoChannelModel } from '../../models/video/video-channel'
import { UserModel } from '../../models/account/user'
import * as magnetUtil from 'magnet-uri'
+import { fetchVideo, VideoFetchType } from '../video'
const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
return true
}
-async function isVideoExist (id: string, res: Response) {
- let video: VideoModel | null
-
- if (validator.isInt(id)) {
- video = await VideoModel.loadAndPopulateAccountAndServerAndTags(+id)
- } else { // UUID
- video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(id)
- }
+async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') {
+ const video = await fetchVideo(id, fetchType)
if (video === null) {
res.status(404)
return false
}
- res.locals.video = video
+ if (fetchType !== 'none') res.locals.video = video
return true
}
import { ResultList } from '../../shared'
import { CONFIG } from '../initializers'
-import { ActorModel } from '../models/activitypub/actor'
import { ApplicationModel } from '../models/application/application'
import { pseudoRandomBytesPromise, sha256 } from './core-utils'
import { logger } from './logger'
import { join } from 'path'
import { Instance as ParseTorrent } from 'parse-torrent'
import { remove } from 'fs-extra'
+import * as memoizee from 'memoizee'
function deleteFileAsync (path: string) {
remove(path)
} as ResultList<U>
}
-async function getServerActor () {
- if (getServerActor.serverActor === undefined) {
- const application = await ApplicationModel.load()
- if (!application) throw Error('Could not load Application from database.')
+const getServerActor = memoizee(async function () {
+ const application = await ApplicationModel.load()
+ if (!application) throw Error('Could not load Application from database.')
- getServerActor.serverActor = application.Account.Actor
- }
-
- if (!getServerActor.serverActor) {
- logger.error('Cannot load server actor.')
- process.exit(0)
- }
-
- return Promise.resolve(getServerActor.serverActor)
-}
-namespace getServerActor {
- export let serverActor: ActorModel
-}
+ return application.Account.Actor
+})
function generateVideoTmpPath (target: string | ParseTorrent) {
const id = typeof target === 'string' ? target : target.infoHash
--- /dev/null
+import { VideoModel } from '../models/video/video'
+
+type VideoFetchType = 'all' | 'only-video' | 'id' | 'none'
+
+function fetchVideo (id: number | string, fetchType: VideoFetchType) {
+ if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id)
+
+ if (fetchType === 'only-video') return VideoModel.load(id)
+
+ if (fetchType === 'id' || fetchType === 'none') return VideoModel.loadOnlyId(id)
+}
+
+type VideoFetchByUrlType = 'all' | 'only-video'
+function fetchVideoByUrl (url: string, fetchType: VideoFetchByUrlType) {
+ if (fetchType === 'all') return VideoModel.loadByUrlAndPopulateAccount(url)
+
+ if (fetchType === 'only-video') return VideoModel.loadByUrl(url)
+}
+
+export {
+ VideoFetchType,
+ VideoFetchByUrlType,
+ fetchVideo,
+ fetchVideoByUrl
+}
request_timeout: 3000
})
-async function loadActorUrlOrGetFromWebfinger (uri: string) {
+async function loadActorUrlOrGetFromWebfinger (uriArg: string) {
+ // Handle strings like @toto@example.com
+ const uri = uriArg.startsWith('@') ? uriArg.slice(1) : uriArg
+
const [ name, host ] = uri.split('@')
let actor: ActorModel
if (timer) clearTimeout(timer)
return safeWebtorrentDestroy(webtorrent, torrentId, file.name, target.torrentName)
- .then(() => rej(new Error('The number of files is not equal to 1 for ' + torrentId)))
+ .then(() => rej(new Error('Cannot import torrent ' + torrentId + ': there are multiple files in it')))
}
file = torrent.files[ 0 ]
import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES } from '../initializers'
import { logger } from './logger'
import { generateVideoTmpPath } from './utils'
-import { YoutubeDlUpdateScheduler } from '../lib/schedulers/youtube-dl-update-scheduler'
+import { join } from 'path'
+import { root } from './core-utils'
+import { ensureDir, writeFile } from 'fs-extra'
+import * as request from 'request'
+import { createWriteStream } from 'fs'
export type YoutubeDLInfo = {
name?: string
return new Promise<string>(async (res, rej) => {
const youtubeDL = await safeGetYoutubeDL()
- youtubeDL.exec(url, options, async (err, output) => {
+ youtubeDL.exec(url, options, err => {
if (err) return rej(err)
return res(path)
})
}
+// Thanks: https://github.com/przemyslawpluta/node-youtube-dl/blob/master/lib/downloader.js
+// We rewrote it to avoid sync calls
+async function updateYoutubeDLBinary () {
+ logger.info('Updating youtubeDL binary.')
+
+ const binDirectory = join(root(), 'node_modules', 'youtube-dl', 'bin')
+ const bin = join(binDirectory, 'youtube-dl')
+ const detailsPath = join(binDirectory, 'details')
+ const url = 'https://yt-dl.org/downloads/latest/youtube-dl'
+
+ await ensureDir(binDirectory)
+
+ return new Promise(res => {
+ request.get(url, { followRedirect: false }, (err, result) => {
+ if (err) {
+ logger.error('Cannot update youtube-dl.', { err })
+ return res()
+ }
+
+ if (result.statusCode !== 302) {
+ logger.error('youtube-dl update error: did not get redirect for the latest version link. Status %d', result.statusCode)
+ return res()
+ }
+
+ const url = result.headers.location
+ const downloadFile = request.get(url)
+ const newVersion = /yt-dl\.org\/downloads\/(\d{4}\.\d\d\.\d\d(\.\d)?)\/youtube-dl/.exec(url)[ 1 ]
+
+ downloadFile.on('response', result => {
+ if (result.statusCode !== 200) {
+ logger.error('Cannot update youtube-dl: new version response is not 200, it\'s %d.', result.statusCode)
+ return res()
+ }
+
+ downloadFile.pipe(createWriteStream(bin, { mode: 493 }))
+ })
+
+ downloadFile.on('error', err => {
+ logger.error('youtube-dl update error.', { err })
+ return res()
+ })
+
+ downloadFile.on('end', () => {
+ const details = JSON.stringify({ version: newVersion, path: bin, exec: 'youtube-dl' })
+ writeFile(detailsPath, details, { encoding: 'utf8' }, err => {
+ if (err) {
+ logger.error('youtube-dl update error: cannot write details.', { err })
+ return res()
+ }
+
+ logger.info('youtube-dl updated to version %s.', newVersion)
+ return res()
+ })
+ })
+ })
+ })
+}
+
async function safeGetYoutubeDL () {
let youtubeDL
youtubeDL = require('youtube-dl')
} catch (e) {
// Download binary
- await YoutubeDlUpdateScheduler.Instance.execute()
+ await updateYoutubeDLBinary()
youtubeDL = require('youtube-dl')
}
// ---------------------------------------------------------------------------
export {
+ updateYoutubeDLBinary,
downloadYoutubeDLVideo,
getYoutubeDLInfo,
safeGetYoutubeDL
import { CONFIG } from './constants'
import { logger } from '../helpers/logger'
import { getServerActor } from '../helpers/utils'
-import { VideosRedundancy } from '../../shared/models/redundancy'
+import { RecentlyAddedStrategy, VideosRedundancy } from '../../shared/models/redundancy'
import { isArray } from '../helpers/custom-validators/misc'
import { uniq } from 'lodash'
function checkConfig () {
const defaultNSFWPolicy = config.get<string>('instance.default_nsfw_policy')
+ // NSFW policy
if ([ 'do_not_list', 'blur', 'display' ].indexOf(defaultNSFWPolicy) === -1) {
return 'NSFW policy setting should be "do_not_list" or "blur" or "display" instead of ' + defaultNSFWPolicy
}
- const redundancyVideos = config.get<VideosRedundancy[]>('redundancy.videos')
+ // Redundancies
+ const redundancyVideos = config.get<VideosRedundancy[]>('redundancy.videos.strategies')
if (isArray(redundancyVideos)) {
for (const r of redundancyVideos) {
- if ([ 'most-views' ].indexOf(r.strategy) === -1) {
+ if ([ 'most-views', 'trending', 'recently-added' ].indexOf(r.strategy) === -1) {
return 'Redundancy video entries should have "most-views" strategy instead of ' + r.strategy
}
}
const filtered = uniq(redundancyVideos.map(r => r.strategy))
if (filtered.length !== redundancyVideos.length) {
- return 'Redundancy video entries should have uniq strategies'
+ return 'Redundancy video entries should have unique strategies'
+ }
+
+ const recentlyAddedStrategy = redundancyVideos.find(r => r.strategy === 'recently-added') as RecentlyAddedStrategy
+ if (recentlyAddedStrategy && isNaN(recentlyAddedStrategy.minViews)) {
+ return 'Min views in recently added strategy is not a number'
}
}
'cache.previews.size', 'admin.email',
'signup.enabled', 'signup.limit', 'signup.requires_email_verification',
'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
+ 'redundancy.videos.strategies', 'redundancy.videos.check_interval',
'transcoding.enabled', 'transcoding.threads',
'import.videos.http.enabled', 'import.videos.torrent.enabled',
'trending.videos.interval_days',
import { IConfig } from 'config'
import { dirname, join } from 'path'
-import { JobType, VideoRateType, VideoRedundancyStrategy, VideoState, VideosRedundancy } from '../../shared/models'
+import { JobType, VideoRateType, VideoState, VideosRedundancy } from '../../shared/models'
import { ActivityPubActorType } from '../../shared/models/activitypub'
import { FollowState } from '../../shared/models/actors'
import { VideoAbuseState, VideoImportState, VideoPrivacy } from '../../shared/models/videos'
// Do not use barrels, remain constants as independent as possible
-import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils'
+import { buildPath, isTestInstance, parseDuration, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils'
import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
import { invert } from 'lodash'
import { CronRepeatOptions, EveryRepeatOptions } from 'bull'
},
ACTIVITY_PUB: {
VIDEOS: '1 second' // 1 second, cache concurrent requests after a broadcast for example
- }
+ },
+ STATS: '4 hours'
}
// ---------------------------------------------------------------------------
badActorFollow: 60000 * 60, // 1 hour
removeOldJobs: 60000 * 60, // 1 hour
updateVideos: 60000, // 1 minute
- youtubeDLUpdate: 60000 * 60 * 24, // 1 day
- videosRedundancy: 60000 * 2 // 2 hours
+ youtubeDLUpdate: 60000 * 60 * 24 // 1 day
}
// ---------------------------------------------------------------------------
}
},
REDUNDANCY: {
- VIDEOS: buildVideosRedundancy(config.get<any[]>('redundancy.videos'))
+ VIDEOS: {
+ CHECK_INTERVAL: parseDuration(config.get<string>('redundancy.videos.check_interval')),
+ STRATEGIES: buildVideosRedundancy(config.get<any[]>('redundancy.videos.strategies'))
+ }
},
ADMIN: {
get EMAIL () { return config.get<string>('admin.email') }
}
}
+const MEMOIZE_TTL = {
+ OVERVIEWS_SAMPLE: 1000 * 3600 * 4 // 4 hours
+}
+
const REDUNDANCY = {
VIDEOS: {
EXPIRES_AFTER_MS: 48 * 3600 * 1000, // 2 days
SCHEDULER_INTERVALS_MS.badActorFollow = 10000
SCHEDULER_INTERVALS_MS.removeOldJobs = 10000
SCHEDULER_INTERVALS_MS.updateVideos = 5000
- SCHEDULER_INTERVALS_MS.videosRedundancy = 5000
REPEAT_JOBS['videos-views'] = { every: 5000 }
REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR = 1
JOB_ATTEMPTS['email'] = 1
CACHE.VIDEO_CAPTIONS.MAX_AGE = 3000
+ MEMOIZE_TTL.OVERVIEWS_SAMPLE = 1
+ ROUTE_CACHE_LIFETIME.OVERVIEWS.VIDEOS = '0ms'
}
updateWebserverConfig()
VIDEO_ABUSE_STATES,
JOB_REQUEST_TIMEOUT,
USER_PASSWORD_RESET_LIFETIME,
+ MEMOIZE_TTL,
USER_EMAIL_VERIFY_LIFETIME,
IMAGE_MIMETYPE_EXT,
OVERVIEWS,
CONFIG.WEBSERVER.HOST = sanitizeHost(CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT, REMOTE_SCHEME.HTTP)
}
-function buildVideosRedundancy (objs: { strategy: VideoRedundancyStrategy, size: string }[]): VideosRedundancy[] {
+function buildVideosRedundancy (objs: VideosRedundancy[]): VideosRedundancy[] {
if (!objs) return []
- return objs.map(obj => {
- return {
- strategy: obj.strategy,
- size: bytes.parse(obj.size)
- }
- })
+ return objs.map(obj => Object.assign(obj, { size: bytes.parse(obj.size) }))
}
function buildLanguages () {
import { VideoChannelModel } from '../../models/video/video-channel'
import { JobQueue } from '../job-queue'
import { getServerActor } from '../../helpers/utils'
+import { ActorFetchByUrlType, fetchActorByUrl } from '../../helpers/actor'
// Set account keys, this could be long so process after the account creation and do not block the client
function setAsyncActorKeys (actor: ActorModel) {
async function getOrCreateActorAndServerAndModel (
activityActor: string | ActivityPubActor,
+ fetchType: ActorFetchByUrlType = 'actor-and-association-ids',
recurseIfNeeded = true,
updateCollections = false
) {
const actorUrl = getActorUrl(activityActor)
let created = false
- let actor = await ActorModel.loadByUrl(actorUrl)
+ let actor = await fetchActorByUrl(actorUrl, fetchType)
// Orphan actor (not associated to an account of channel) so recreate it
if (actor && (!actor.Account && !actor.VideoChannel)) {
await actor.destroy()
try {
// Assert we don't recurse another time
- ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, false)
+ ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', false)
} catch (err) {
logger.error('Cannot get or create account attributed to video channel ' + actor.url)
throw new Error(err)
if (actor.Account) actor.Account.Actor = actor
if (actor.VideoChannel) actor.VideoChannel.Actor = actor
- const { actor: actorRefreshed, refreshed } = await retryTransactionWrapper(refreshActorIfNeeded, actor)
+ const { actor: actorRefreshed, refreshed } = await retryTransactionWrapper(refreshActorIfNeeded, actor, fetchType)
if (!actorRefreshed) throw new Error('Actor ' + actorRefreshed.url + ' does not exist anymore.')
if ((created === true || refreshed === true) && updateCollections === true) {
return videoChannelCreated
}
-async function refreshActorIfNeeded (actor: ActorModel): Promise<{ actor: ActorModel, refreshed: boolean }> {
- if (!actor.isOutdated()) return { actor, refreshed: false }
+async function refreshActorIfNeeded (
+ actorArg: ActorModel,
+ fetchedType: ActorFetchByUrlType
+): Promise<{ actor: ActorModel, refreshed: boolean }> {
+ if (!actorArg.isOutdated()) return { actor: actorArg, refreshed: false }
+
+ // We need more attributes
+ const actor = fetchedType === 'all' ? actorArg : await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorArg.url)
try {
const actorUrl = await getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
import { VideoCommentModel } from '../../models/video/video-comment'
import { VideoShareModel } from '../../models/video/video-share'
-function getVideoAudience (video: VideoModel, actorsInvolvedInVideo: ActorModel[]) {
+function getRemoteVideoAudience (video: VideoModel, actorsInvolvedInVideo: ActorModel[]): ActivityAudience {
return {
to: [ video.VideoChannel.Account.Actor.url ],
cc: actorsInvolvedInVideo.map(a => a.followersUrl)
threadParentComments: VideoCommentModel[],
actorsInvolvedInVideo: ActorModel[],
isOrigin = false
-) {
+): ActivityAudience {
const to = [ ACTIVITY_PUB.PUBLIC ]
const cc: string[] = []
}
}
-function getObjectFollowersAudience (actorsInvolvedInObject: ActorModel[]) {
+function getAudienceFromFollowersOf (actorsInvolvedInObject: ActorModel[]): ActivityAudience {
return {
to: [ ACTIVITY_PUB.PUBLIC ].concat(actorsInvolvedInObject.map(a => a.followersUrl)),
cc: []
export {
buildAudience,
getAudience,
- getVideoAudience,
+ getRemoteVideoAudience,
getActorsInvolvedInVideo,
- getObjectFollowersAudience,
+ getAudienceFromFollowersOf,
audiencify,
getVideoCommentAudience
}
import { CacheFileObject } from '../../../shared/index'
import { VideoModel } from '../../models/video/video'
-import { ActorModel } from '../../models/activitypub/actor'
import { sequelizeTypescript } from '../../initializers'
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
-function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: VideoModel, byActor: ActorModel) {
+function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: VideoModel, byActor: { id?: number }) {
const url = cacheFileObject.url
const videoFile = video.VideoFiles.find(f => {
}
}
-function createCacheFile (cacheFileObject: CacheFileObject, video: VideoModel, byActor: ActorModel) {
+function createCacheFile (cacheFileObject: CacheFileObject, video: VideoModel, byActor: { id?: number }) {
return sequelizeTypescript.transaction(async t => {
const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor)
})
}
-function updateCacheFile (cacheFileObject: CacheFileObject, redundancyModel: VideoRedundancyModel, byActor: ActorModel) {
+function updateCacheFile (cacheFileObject: CacheFileObject, redundancyModel: VideoRedundancyModel, byActor: { id?: number }) {
+ if (redundancyModel.actorId !== byActor.id) {
+ throw new Error('Cannot update redundancy ' + redundancyModel.url + ' of another actor.')
+ }
+
const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, redundancyModel.VideoFile.Video, byActor)
redundancyModel.set('expires', attributes.expiresOn)
import { ActivityAccept } from '../../../../shared/models/activitypub'
-import { getActorUrl } from '../../../helpers/activitypub'
import { ActorModel } from '../../../models/activitypub/actor'
import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
import { addFetchOutboxJob } from '../actor'
-async function processAcceptActivity (activity: ActivityAccept, inboxActor?: ActorModel) {
+async function processAcceptActivity (activity: ActivityAccept, targetActor: ActorModel, inboxActor?: ActorModel) {
if (inboxActor === undefined) throw new Error('Need to accept on explicit inbox.')
- const actorUrl = getActorUrl(activity.actor)
- const targetActor = await ActorModel.loadByUrl(actorUrl)
-
return processAccept(inboxActor, targetActor)
}
import { retryTransactionWrapper } from '../../../helpers/database-utils'
import { sequelizeTypescript } from '../../../initializers'
import { ActorModel } from '../../../models/activitypub/actor'
-import { VideoModel } from '../../../models/video/video'
import { VideoShareModel } from '../../../models/video/video-share'
-import { getOrCreateActorAndServerAndModel } from '../actor'
import { forwardVideoRelatedActivity } from '../send/utils'
import { getOrCreateVideoAndAccountAndChannel } from '../videos'
-async function processAnnounceActivity (activity: ActivityAnnounce) {
- const actorAnnouncer = await getOrCreateActorAndServerAndModel(activity.actor)
-
+async function processAnnounceActivity (activity: ActivityAnnounce, actorAnnouncer: ActorModel) {
return retryTransactionWrapper(processVideoShare, actorAnnouncer, activity)
}
async function processVideoShare (actorAnnouncer: ActorModel, activity: ActivityAnnounce) {
const objectUri = typeof activity.object === 'string' ? activity.object : activity.object.id
- const { video } = await getOrCreateVideoAndAccountAndChannel(objectUri)
+ const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: objectUri })
return sequelizeTypescript.transaction(async t => {
// Add share entry
import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
import { ActorModel } from '../../../models/activitypub/actor'
import { VideoAbuseModel } from '../../../models/video/video-abuse'
-import { getOrCreateActorAndServerAndModel } from '../actor'
import { addVideoComment, resolveThread } from '../video-comments'
import { getOrCreateVideoAndAccountAndChannel } from '../videos'
import { forwardActivity, forwardVideoRelatedActivity } from '../send/utils'
import { Redis } from '../../redis'
import { createCacheFile } from '../cache-file'
-async function processCreateActivity (activity: ActivityCreate) {
+async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) {
const activityObject = activity.object
const activityType = activityObject.type
- const actor = await getOrCreateActorAndServerAndModel(activity.actor)
if (activityType === 'View') {
- return processCreateView(actor, activity)
+ return processCreateView(byActor, activity)
} else if (activityType === 'Dislike') {
- return retryTransactionWrapper(processCreateDislike, actor, activity)
+ return retryTransactionWrapper(processCreateDislike, byActor, activity)
} else if (activityType === 'Video') {
return processCreateVideo(activity)
} else if (activityType === 'Flag') {
- return retryTransactionWrapper(processCreateVideoAbuse, actor, activityObject as VideoAbuseObject)
+ return retryTransactionWrapper(processCreateVideoAbuse, byActor, activityObject as VideoAbuseObject)
} else if (activityType === 'Note') {
- return retryTransactionWrapper(processCreateVideoComment, actor, activity)
+ return retryTransactionWrapper(processCreateVideoComment, byActor, activity)
} else if (activityType === 'CacheFile') {
- return retryTransactionWrapper(processCacheFile, actor, activity)
+ return retryTransactionWrapper(processCacheFile, byActor, activity)
}
logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id })
async function processCreateVideo (activity: ActivityCreate) {
const videoToCreateData = activity.object as VideoTorrentObject
- const { video } = await getOrCreateVideoAndAccountAndChannel(videoToCreateData)
+ const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData })
return video
}
if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
- const { video } = await getOrCreateVideoAndAccountAndChannel(dislike.object)
+ const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislike.object })
return sequelizeTypescript.transaction(async t => {
const rate = {
async function processCreateView (byActor: ActorModel, activity: ActivityCreate) {
const view = activity.object as ViewObject
- const { video } = await getOrCreateVideoAndAccountAndChannel(view.object)
+ const options = {
+ videoObject: view.object,
+ fetchType: 'only-video' as 'only-video'
+ }
+ const { video } = await getOrCreateVideoAndAccountAndChannel(options)
- const actor = await ActorModel.loadByUrl(view.actor)
- if (!actor) throw new Error('Unknown actor ' + view.actor)
+ const actorExists = await ActorModel.isActorUrlExist(view.actor)
+ if (actorExists === false) throw new Error('Unknown actor ' + view.actor)
await Redis.Instance.addVideoView(video.id)
async function processCacheFile (byActor: ActorModel, activity: ActivityCreate) {
const cacheFile = activity.object as CacheFileObject
- const { video } = await getOrCreateVideoAndAccountAndChannel(cacheFile.object)
+ const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFile.object })
await createCacheFile(cacheFile, video, byActor)
}
}
-async function processCreateVideoAbuse (actor: ActorModel, videoAbuseToCreateData: VideoAbuseObject) {
+async function processCreateVideoAbuse (byActor: ActorModel, videoAbuseToCreateData: VideoAbuseObject) {
logger.debug('Reporting remote abuse for video %s.', videoAbuseToCreateData.object)
- const account = actor.Account
- if (!account) throw new Error('Cannot create dislike with the non account actor ' + actor.url)
+ const account = byActor.Account
+ if (!account) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
- const { video } = await getOrCreateVideoAndAccountAndChannel(videoAbuseToCreateData.object)
+ const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoAbuseToCreateData.object })
return sequelizeTypescript.transaction(async t => {
const videoAbuseData = {
import { VideoModel } from '../../../models/video/video'
import { VideoChannelModel } from '../../../models/video/video-channel'
import { VideoCommentModel } from '../../../models/video/video-comment'
-import { getOrCreateActorAndServerAndModel } from '../actor'
import { forwardActivity } from '../send/utils'
-async function processDeleteActivity (activity: ActivityDelete) {
+async function processDeleteActivity (activity: ActivityDelete, byActor: ActorModel) {
const objectUrl = typeof activity.object === 'string' ? activity.object : activity.object.id
if (activity.actor === objectUrl) {
- let actor = await ActorModel.loadByUrl(activity.actor)
- if (!actor) return undefined
+ // We need more attributes (all the account and channel)
+ const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
- if (actor.type === 'Person') {
- if (!actor.Account) throw new Error('Actor ' + actor.url + ' is a person but we cannot find it in database.')
+ if (byActorFull.type === 'Person') {
+ if (!byActorFull.Account) throw new Error('Actor ' + byActorFull.url + ' is a person but we cannot find it in database.')
- actor.Account.Actor = await actor.Account.$get('Actor') as ActorModel
- return retryTransactionWrapper(processDeleteAccount, actor.Account)
- } else if (actor.type === 'Group') {
- if (!actor.VideoChannel) throw new Error('Actor ' + actor.url + ' is a group but we cannot find it in database.')
+ byActorFull.Account.Actor = await byActorFull.Account.$get('Actor') as ActorModel
+ return retryTransactionWrapper(processDeleteAccount, byActorFull.Account)
+ } else if (byActorFull.type === 'Group') {
+ if (!byActorFull.VideoChannel) throw new Error('Actor ' + byActorFull.url + ' is a group but we cannot find it in database.')
- actor.VideoChannel.Actor = await actor.VideoChannel.$get('Actor') as ActorModel
- return retryTransactionWrapper(processDeleteVideoChannel, actor.VideoChannel)
+ byActorFull.VideoChannel.Actor = await byActorFull.VideoChannel.$get('Actor') as ActorModel
+ return retryTransactionWrapper(processDeleteVideoChannel, byActorFull.VideoChannel)
}
}
- const actor = await getOrCreateActorAndServerAndModel(activity.actor)
{
const videoCommentInstance = await VideoCommentModel.loadByUrlAndPopulateAccount(objectUrl)
if (videoCommentInstance) {
- return retryTransactionWrapper(processDeleteVideoComment, actor, videoCommentInstance, activity)
+ return retryTransactionWrapper(processDeleteVideoComment, byActor, videoCommentInstance, activity)
}
}
{
const videoInstance = await VideoModel.loadByUrlAndPopulateAccount(objectUrl)
if (videoInstance) {
- return retryTransactionWrapper(processDeleteVideo, actor, videoInstance)
+ if (videoInstance.isOwned()) throw new Error(`Remote instance cannot delete owned video ${videoInstance.url}.`)
+
+ return retryTransactionWrapper(processDeleteVideo, byActor, videoInstance)
}
}
logger.debug('Removing remote video comment "%s".', videoComment.url)
return sequelizeTypescript.transaction(async t => {
+ if (videoComment.Account.id !== byActor.Account.id) {
+ throw new Error('Account ' + byActor.url + ' does not own video comment ' + videoComment.url)
+ }
+
await videoComment.destroy({ transaction: t })
if (videoComment.Video.isOwned()) {
import { sequelizeTypescript } from '../../../initializers'
import { ActorModel } from '../../../models/activitypub/actor'
import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
-import { getOrCreateActorAndServerAndModel } from '../actor'
import { sendAccept } from '../send'
-async function processFollowActivity (activity: ActivityFollow) {
+async function processFollowActivity (activity: ActivityFollow, byActor: ActorModel) {
const activityObject = activity.object
- const actor = await getOrCreateActorAndServerAndModel(activity.actor)
- return retryTransactionWrapper(processFollow, actor, activityObject)
+ return retryTransactionWrapper(processFollow, byActor, activityObject)
}
// ---------------------------------------------------------------------------
async function processFollow (actor: ActorModel, targetActorURL: string) {
await sequelizeTypescript.transaction(async t => {
- const targetActor = await ActorModel.loadByUrl(targetActorURL, t)
+ const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t)
if (!targetActor) throw new Error('Unknown actor')
if (targetActor.isOwned() === false) throw new Error('This is not a local actor.')
import { sequelizeTypescript } from '../../../initializers'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
import { ActorModel } from '../../../models/activitypub/actor'
-import { getOrCreateActorAndServerAndModel } from '../actor'
import { forwardVideoRelatedActivity } from '../send/utils'
import { getOrCreateVideoAndAccountAndChannel } from '../videos'
-async function processLikeActivity (activity: ActivityLike) {
- const actor = await getOrCreateActorAndServerAndModel(activity.actor)
-
- return retryTransactionWrapper(processLikeVideo, actor, activity)
+async function processLikeActivity (activity: ActivityLike, byActor: ActorModel) {
+ return retryTransactionWrapper(processLikeVideo, byActor, activity)
}
// ---------------------------------------------------------------------------
const byAccount = byActor.Account
if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url)
- const { video } = await getOrCreateVideoAndAccountAndChannel(videoUrl)
+ const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoUrl })
return sequelizeTypescript.transaction(async t => {
const rate = {
import { ActivityReject } from '../../../../shared/models/activitypub/activity'
-import { getActorUrl } from '../../../helpers/activitypub'
import { sequelizeTypescript } from '../../../initializers'
import { ActorModel } from '../../../models/activitypub/actor'
import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
-async function processRejectActivity (activity: ActivityReject, inboxActor?: ActorModel) {
+async function processRejectActivity (activity: ActivityReject, targetActor: ActorModel, inboxActor?: ActorModel) {
if (inboxActor === undefined) throw new Error('Need to reject on explicit inbox.')
- const actorUrl = getActorUrl(activity.actor)
- const targetActor = await ActorModel.loadByUrl(actorUrl)
-
return processReject(inboxActor, targetActor)
}
// ---------------------------------------------------------------------------
-async function processReject (actor: ActorModel, targetActor: ActorModel) {
+async function processReject (follower: ActorModel, targetActor: ActorModel) {
return sequelizeTypescript.transaction(async t => {
- const actorFollow = await ActorFollowModel.loadByActorAndTarget(actor.id, targetActor.id, t)
+ const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, targetActor.id, t)
- if (!actorFollow) throw new Error(`'Unknown actor follow ${actor.id} -> ${targetActor.id}.`)
+ if (!actorFollow) throw new Error(`'Unknown actor follow ${follower.id} -> ${targetActor.id}.`)
await actorFollow.destroy({ transaction: t })
import { ActivityAnnounce, ActivityFollow, ActivityLike, ActivityUndo, CacheFileObject } from '../../../../shared/models/activitypub'
import { DislikeObject } from '../../../../shared/models/activitypub/objects'
-import { getActorUrl } from '../../../helpers/activitypub'
import { retryTransactionWrapper } from '../../../helpers/database-utils'
import { logger } from '../../../helpers/logger'
import { sequelizeTypescript } from '../../../initializers'
-import { AccountModel } from '../../../models/account/account'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
import { ActorModel } from '../../../models/activitypub/actor'
import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
import { VideoShareModel } from '../../../models/video/video-share'
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
-async function processUndoActivity (activity: ActivityUndo) {
+async function processUndoActivity (activity: ActivityUndo, byActor: ActorModel) {
const activityToUndo = activity.object
- const actorUrl = getActorUrl(activity.actor)
-
if (activityToUndo.type === 'Like') {
- return retryTransactionWrapper(processUndoLike, actorUrl, activity)
+ return retryTransactionWrapper(processUndoLike, byActor, activity)
}
if (activityToUndo.type === 'Create') {
if (activityToUndo.object.type === 'Dislike') {
- return retryTransactionWrapper(processUndoDislike, actorUrl, activity)
+ return retryTransactionWrapper(processUndoDislike, byActor, activity)
} else if (activityToUndo.object.type === 'CacheFile') {
- return retryTransactionWrapper(processUndoCacheFile, actorUrl, activity)
+ return retryTransactionWrapper(processUndoCacheFile, byActor, activity)
}
}
if (activityToUndo.type === 'Follow') {
- return retryTransactionWrapper(processUndoFollow, actorUrl, activityToUndo)
+ return retryTransactionWrapper(processUndoFollow, byActor, activityToUndo)
}
if (activityToUndo.type === 'Announce') {
- return retryTransactionWrapper(processUndoAnnounce, actorUrl, activityToUndo)
+ return retryTransactionWrapper(processUndoAnnounce, byActor, activityToUndo)
}
logger.warn('Unknown activity object type %s -> %s when undo activity.', activityToUndo.type, { activity: activity.id })
// ---------------------------------------------------------------------------
-async function processUndoLike (actorUrl: string, activity: ActivityUndo) {
+async function processUndoLike (byActor: ActorModel, activity: ActivityUndo) {
const likeActivity = activity.object as ActivityLike
- const { video } = await getOrCreateVideoAndAccountAndChannel(likeActivity.object)
+ const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: likeActivity.object })
return sequelizeTypescript.transaction(async t => {
- const byAccount = await AccountModel.loadByUrl(actorUrl, t)
- if (!byAccount) throw new Error('Unknown account ' + actorUrl)
+ if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
- const rate = await AccountVideoRateModel.load(byAccount.id, video.id, t)
- if (!rate) throw new Error(`Unknown rate by account ${byAccount.id} for video ${video.id}.`)
+ const rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t)
+ if (!rate) throw new Error(`Unknown rate by account ${byActor.Account.id} for video ${video.id}.`)
await rate.destroy({ transaction: t })
await video.decrement('likes', { transaction: t })
if (video.isOwned()) {
// Don't resend the activity to the sender
- const exceptions = [ byAccount.Actor ]
+ const exceptions = [ byActor ]
await forwardVideoRelatedActivity(activity, t, exceptions, video)
}
})
}
-async function processUndoDislike (actorUrl: string, activity: ActivityUndo) {
+async function processUndoDislike (byActor: ActorModel, activity: ActivityUndo) {
const dislike = activity.object.object as DislikeObject
- const { video } = await getOrCreateVideoAndAccountAndChannel(dislike.object)
+ const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislike.object })
return sequelizeTypescript.transaction(async t => {
- const byAccount = await AccountModel.loadByUrl(actorUrl, t)
- if (!byAccount) throw new Error('Unknown account ' + actorUrl)
+ if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
- const rate = await AccountVideoRateModel.load(byAccount.id, video.id, t)
- if (!rate) throw new Error(`Unknown rate by account ${byAccount.id} for video ${video.id}.`)
+ const rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t)
+ if (!rate) throw new Error(`Unknown rate by account ${byActor.Account.id} for video ${video.id}.`)
await rate.destroy({ transaction: t })
await video.decrement('dislikes', { transaction: t })
if (video.isOwned()) {
// Don't resend the activity to the sender
- const exceptions = [ byAccount.Actor ]
+ const exceptions = [ byActor ]
await forwardVideoRelatedActivity(activity, t, exceptions, video)
}
})
}
-async function processUndoCacheFile (actorUrl: string, activity: ActivityUndo) {
+async function processUndoCacheFile (byActor: ActorModel, activity: ActivityUndo) {
const cacheFileObject = activity.object.object as CacheFileObject
- const { video } = await getOrCreateVideoAndAccountAndChannel(cacheFileObject.object)
+ const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFileObject.object })
return sequelizeTypescript.transaction(async t => {
- const byActor = await ActorModel.loadByUrl(actorUrl)
- if (!byActor) throw new Error('Unknown actor ' + actorUrl)
-
const cacheFile = await VideoRedundancyModel.loadByUrl(cacheFileObject.id)
if (!cacheFile) throw new Error('Unknown video cache ' + cacheFile.url)
+ if (cacheFile.actorId !== byActor.id) throw new Error('Cannot delete redundancy ' + cacheFile.url + ' of another actor.')
+
await cacheFile.destroy()
if (video.isOwned()) {
})
}
-function processUndoFollow (actorUrl: string, followActivity: ActivityFollow) {
+function processUndoFollow (follower: ActorModel, followActivity: ActivityFollow) {
return sequelizeTypescript.transaction(async t => {
- const follower = await ActorModel.loadByUrl(actorUrl, t)
- const following = await ActorModel.loadByUrl(followActivity.object, t)
+ const following = await ActorModel.loadByUrlAndPopulateAccountAndChannel(followActivity.object, t)
const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, following.id, t)
if (!actorFollow) throw new Error(`'Unknown actor follow ${follower.id} -> ${following.id}.`)
})
}
-function processUndoAnnounce (actorUrl: string, announceActivity: ActivityAnnounce) {
+function processUndoAnnounce (byActor: ActorModel, announceActivity: ActivityAnnounce) {
return sequelizeTypescript.transaction(async t => {
- const byActor = await ActorModel.loadByUrl(actorUrl, t)
- if (!byActor) throw new Error('Unknown actor ' + actorUrl)
-
const share = await VideoShareModel.loadByUrl(announceActivity.id, t)
if (!share) throw new Error(`Unknown video share ${announceActivity.id}.`)
import { AccountModel } from '../../../models/account/account'
import { ActorModel } from '../../../models/activitypub/actor'
import { VideoChannelModel } from '../../../models/video/video-channel'
-import { fetchAvatarIfExists, getOrCreateActorAndServerAndModel, updateActorAvatarInstance, updateActorInstance } from '../actor'
-import { getOrCreateVideoAndAccountAndChannel, updateVideoFromAP, getOrCreateVideoChannelFromVideoObject } from '../videos'
+import { fetchAvatarIfExists, updateActorAvatarInstance, updateActorInstance } from '../actor'
+import { getOrCreateVideoAndAccountAndChannel, getOrCreateVideoChannelFromVideoObject, updateVideoFromAP } from '../videos'
import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file'
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
import { createCacheFile, updateCacheFile } from '../cache-file'
-async function processUpdateActivity (activity: ActivityUpdate) {
- const actor = await getOrCreateActorAndServerAndModel(activity.actor)
+async function processUpdateActivity (activity: ActivityUpdate, byActor: ActorModel) {
const objectType = activity.object.type
if (objectType === 'Video') {
- return retryTransactionWrapper(processUpdateVideo, actor, activity)
+ return retryTransactionWrapper(processUpdateVideo, byActor, activity)
}
if (objectType === 'Person' || objectType === 'Application' || objectType === 'Group') {
- return retryTransactionWrapper(processUpdateActor, actor, activity)
+ // We need more attributes
+ const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
+ return retryTransactionWrapper(processUpdateActor, byActorFull, activity)
}
if (objectType === 'CacheFile') {
- return retryTransactionWrapper(processUpdateCacheFile, actor, activity)
+ // We need more attributes
+ const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
+ return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity)
}
return undefined
return undefined
}
- const { video } = await getOrCreateVideoAndAccountAndChannel(videoObject.id)
+ const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoObject.id })
const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
- return updateVideoFromAP(video, videoObject, actor.Account, channelActor.VideoChannel, activity.to)
+ const updateOptions = {
+ video,
+ videoObject,
+ account: actor.Account,
+ channel: channelActor.VideoChannel,
+ updateViews: true,
+ overrideTo: activity.to
+ }
+ return updateVideoFromAP(updateOptions)
}
async function processUpdateCacheFile (byActor: ActorModel, activity: ActivityUpdate) {
const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id)
if (!redundancyModel) {
- const { video } = await getOrCreateVideoAndAccountAndChannel(cacheFileObject.id)
+ const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFileObject.id })
return createCacheFile(cacheFileObject, video, byActor)
}
import { processRejectActivity } from './process-reject'
import { processUndoActivity } from './process-undo'
import { processUpdateActivity } from './process-update'
+import { getOrCreateActorAndServerAndModel } from '../actor'
-const processActivity: { [ P in ActivityType ]: (activity: Activity, inboxActor?: ActorModel) => Promise<any> } = {
+const processActivity: { [ P in ActivityType ]: (activity: Activity, byActor: ActorModel, inboxActor?: ActorModel) => Promise<any> } = {
Create: processCreateActivity,
Update: processUpdateActivity,
Delete: processDeleteActivity,
}
async function processActivities (activities: Activity[], signatureActor?: ActorModel, inboxActor?: ActorModel) {
+ const actorsCache: { [ url: string ]: ActorModel } = {}
+
for (const activity of activities) {
+ if (!signatureActor && [ 'Create', 'Announce', 'Like' ].indexOf(activity.type) === -1) {
+ logger.error('Cannot process activity %s (type: %s) without the actor signature.', activity.id, activity.type)
+ continue
+ }
+
const actorUrl = getActorUrl(activity.actor)
// When we fetch remote data, we don't have signature
continue
}
+ const byActor = signatureActor || actorsCache[actorUrl] || await getOrCreateActorAndServerAndModel(actorUrl)
+ actorsCache[actorUrl] = byActor
+
const activityProcessor = processActivity[activity.type]
if (activityProcessor === undefined) {
logger.warn('Unknown activity type %s.', activity.type, { activityId: activity.id })
}
try {
- await activityProcessor(activity, inboxActor)
+ await activityProcessor(activity, byActor, inboxActor)
} catch (err) {
logger.warn('Cannot process activity %s.', activity.type, { err })
}
import { VideoModel } from '../../../models/video/video'
import { VideoShareModel } from '../../../models/video/video-share'
import { broadcastToFollowers } from './utils'
-import { audiencify, getActorsInvolvedInVideo, getAudience, getObjectFollowersAudience } from '../audience'
+import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf } from '../audience'
import { logger } from '../../../helpers/logger'
async function buildAnnounceWithVideoAudience (byActor: ActorModel, videoShare: VideoShareModel, video: VideoModel, t: Transaction) {
const announcedObject = video.url
const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
- const audience = getObjectFollowersAudience(actorsInvolvedInVideo)
+ const audience = getAudienceFromFollowersOf(actorsInvolvedInVideo)
const activity = buildAnnounceActivity(videoShare.url, byActor, announcedObject, audience)
import { Transaction } from 'sequelize'
import { ActivityAudience, ActivityCreate } from '../../../../shared/models/activitypub'
import { VideoPrivacy } from '../../../../shared/models/videos'
-import { getServerActor } from '../../../helpers/utils'
import { ActorModel } from '../../../models/activitypub/actor'
import { VideoModel } from '../../../models/video/video'
import { VideoAbuseModel } from '../../../models/video/video-abuse'
import { VideoCommentModel } from '../../../models/video/video-comment'
import { getVideoAbuseActivityPubUrl, getVideoDislikeActivityPubUrl, getVideoViewActivityPubUrl } from '../url'
-import { broadcastToActors, broadcastToFollowers, unicastTo } from './utils'
-import {
- audiencify,
- getActorsInvolvedInVideo,
- getAudience,
- getObjectFollowersAudience,
- getVideoAudience,
- getVideoCommentAudience
-} from '../audience'
+import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
+import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience'
import { logger } from '../../../helpers/logger'
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
logger.info('Creating job to send video abuse %s.', url)
+ // Custom audience, we only send the abuse to the origin instance
const audience = { to: [ video.VideoChannel.Account.Actor.url ], cc: [] }
const createActivity = buildCreateActivity(url, byActor, videoAbuse.toActivityPubObject(), audience)
async function sendCreateCacheFile (byActor: ActorModel, fileRedundancy: VideoRedundancyModel) {
logger.info('Creating job to send file cache of %s.', fileRedundancy.url)
- const redundancyObject = fileRedundancy.toActivityPubObject()
-
const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(fileRedundancy.VideoFile.Video.id)
- const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, undefined)
-
- const audience = getVideoAudience(video, actorsInvolvedInVideo)
- const createActivity = buildCreateActivity(fileRedundancy.url, byActor, redundancyObject, audience)
+ const redundancyObject = fileRedundancy.toActivityPubObject()
- return unicastTo(createActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
+ return sendVideoRelatedCreateActivity({
+ byActor,
+ video,
+ url: fileRedundancy.url,
+ object: redundancyObject
+ })
}
async function sendCreateVideoComment (comment: VideoCommentModel, t: Transaction) {
const commentObject = comment.toActivityPubObject(threadParentComments)
const actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, t)
+ // Add the actor that commented too
actorsInvolvedInComment.push(byActor)
const parentsCommentActors = threadParentComments.map(c => c.Account.Actor)
if (isOrigin) {
audience = getVideoCommentAudience(comment, threadParentComments, actorsInvolvedInComment, isOrigin)
} else {
- audience = getObjectFollowersAudience(actorsInvolvedInComment.concat(parentsCommentActors))
+ audience = getAudienceFromFollowersOf(actorsInvolvedInComment.concat(parentsCommentActors))
}
const createActivity = buildCreateActivity(comment.url, byActor, commentObject, audience)
const url = getVideoViewActivityPubUrl(byActor, video)
const viewActivity = buildViewActivity(byActor, video)
- const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
-
- // Send to origin
- if (video.isOwned() === false) {
- const audience = getVideoAudience(video, actorsInvolvedInVideo)
- const createActivity = buildCreateActivity(url, byActor, viewActivity, audience)
-
- return unicastTo(createActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
- }
-
- // Send to followers
- const audience = getObjectFollowersAudience(actorsInvolvedInVideo)
- const createActivity = buildCreateActivity(url, byActor, viewActivity, audience)
-
- // Use the server actor to send the view
- const serverActor = await getServerActor()
- const actorsException = [ byActor ]
- return broadcastToFollowers(createActivity, serverActor, actorsInvolvedInVideo, t, actorsException)
+ return sendVideoRelatedCreateActivity({
+ // Use the server actor to send the view
+ byActor,
+ video,
+ url,
+ object: viewActivity,
+ transaction: t
+ })
}
async function sendCreateDislike (byActor: ActorModel, video: VideoModel, t: Transaction) {
const url = getVideoDislikeActivityPubUrl(byActor, video)
const dislikeActivity = buildDislikeActivity(byActor, video)
- const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
-
- // Send to origin
- if (video.isOwned() === false) {
- const audience = getVideoAudience(video, actorsInvolvedInVideo)
- const createActivity = buildCreateActivity(url, byActor, dislikeActivity, audience)
-
- return unicastTo(createActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
- }
-
- // Send to followers
- const audience = getObjectFollowersAudience(actorsInvolvedInVideo)
- const createActivity = buildCreateActivity(url, byActor, dislikeActivity, audience)
-
- const actorsException = [ byActor ]
- return broadcastToFollowers(createActivity, byActor, actorsInvolvedInVideo, t, actorsException)
+ return sendVideoRelatedCreateActivity({
+ byActor,
+ video,
+ url,
+ object: dislikeActivity,
+ transaction: t
+ })
}
function buildCreateActivity (url: string, byActor: ActorModel, object: any, audience?: ActivityAudience): ActivityCreate {
sendCreateVideoComment,
sendCreateCacheFile
}
+
+// ---------------------------------------------------------------------------
+
+async function sendVideoRelatedCreateActivity (options: {
+ byActor: ActorModel,
+ video: VideoModel,
+ url: string,
+ object: any,
+ transaction?: Transaction
+}) {
+ const activityBuilder = (audience: ActivityAudience) => {
+ return buildCreateActivity(options.url, options.byActor, options.object, audience)
+ }
+
+ return sendVideoRelatedActivity(activityBuilder, options)
+}
import { VideoCommentModel } from '../../../models/video/video-comment'
import { VideoShareModel } from '../../../models/video/video-share'
import { getDeleteActivityPubUrl } from '../url'
-import { broadcastToActors, broadcastToFollowers, unicastTo } from './utils'
+import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience'
import { logger } from '../../../helpers/logger'
-async function sendDeleteVideo (video: VideoModel, t: Transaction) {
+async function sendDeleteVideo (video: VideoModel, transaction: Transaction) {
logger.info('Creating job to broadcast delete of video %s.', video.url)
- const url = getDeleteActivityPubUrl(video.url)
const byActor = video.VideoChannel.Account.Actor
- const activity = buildDeleteActivity(url, video.url, byActor)
+ const activityBuilder = (audience: ActivityAudience) => {
+ const url = getDeleteActivityPubUrl(video.url)
- const actorsInvolved = await getActorsInvolvedInVideo(video, t)
+ return buildDeleteActivity(url, video.url, byActor, audience)
+ }
- return broadcastToFollowers(activity, byActor, actorsInvolved, t)
+ return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction })
}
async function sendDeleteActor (byActor: ActorModel, t: Transaction) {
import { ActorModel } from '../../../models/activitypub/actor'
import { VideoModel } from '../../../models/video/video'
import { getVideoLikeActivityPubUrl } from '../url'
-import { broadcastToFollowers, unicastTo } from './utils'
-import { audiencify, getActorsInvolvedInVideo, getAudience, getObjectFollowersAudience, getVideoAudience } from '../audience'
+import { sendVideoRelatedActivity } from './utils'
+import { audiencify, getAudience } from '../audience'
import { logger } from '../../../helpers/logger'
async function sendLike (byActor: ActorModel, video: VideoModel, t: Transaction) {
logger.info('Creating job to like %s.', video.url)
- const url = getVideoLikeActivityPubUrl(byActor, video)
+ const activityBuilder = (audience: ActivityAudience) => {
+ const url = getVideoLikeActivityPubUrl(byActor, video)
- const accountsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
-
- // Send to origin
- if (video.isOwned() === false) {
- const audience = getVideoAudience(video, accountsInvolvedInVideo)
- const data = buildLikeActivity(url, byActor, video, audience)
-
- return unicastTo(data, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
+ return buildLikeActivity(url, byActor, video, audience)
}
- // Send to followers
- const audience = getObjectFollowersAudience(accountsInvolvedInVideo)
- const activity = buildLikeActivity(url, byActor, video, audience)
-
- const followersException = [ byActor ]
- return broadcastToFollowers(activity, byActor, accountsInvolvedInVideo, t, followersException)
+ return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction: t })
}
function buildLikeActivity (url: string, byActor: ActorModel, video: VideoModel, audience?: ActivityAudience): ActivityLike {
import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
import { VideoModel } from '../../../models/video/video'
import { getActorFollowActivityPubUrl, getUndoActivityPubUrl, getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from '../url'
-import { broadcastToFollowers, unicastTo } from './utils'
-import { audiencify, getActorsInvolvedInVideo, getAudience, getObjectFollowersAudience, getVideoAudience } from '../audience'
+import { broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
+import { audiencify, getAudience } from '../audience'
import { buildCreateActivity, buildDislikeActivity } from './send-create'
import { buildFollowActivity } from './send-follow'
import { buildLikeActivity } from './send-like'
return unicastTo(undoActivity, me, following.inboxUrl)
}
-async function sendUndoLike (byActor: ActorModel, video: VideoModel, t: Transaction) {
- logger.info('Creating job to undo a like of video %s.', video.url)
+async function sendUndoAnnounce (byActor: ActorModel, videoShare: VideoShareModel, video: VideoModel, t: Transaction) {
+ logger.info('Creating job to undo announce %s.', videoShare.url)
- const likeUrl = getVideoLikeActivityPubUrl(byActor, video)
- const undoUrl = getUndoActivityPubUrl(likeUrl)
+ const undoUrl = getUndoActivityPubUrl(videoShare.url)
- const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
- const likeActivity = buildLikeActivity(likeUrl, byActor, video)
+ const { activity: announceActivity, actorsInvolvedInVideo } = await buildAnnounceWithVideoAudience(byActor, videoShare, video, t)
+ const undoActivity = undoActivityData(undoUrl, byActor, announceActivity)
- // Send to origin
- if (video.isOwned() === false) {
- const audience = getVideoAudience(video, actorsInvolvedInVideo)
- const undoActivity = undoActivityData(undoUrl, byActor, likeActivity, audience)
+ const followersException = [ byActor ]
+ return broadcastToFollowers(undoActivity, byActor, actorsInvolvedInVideo, t, followersException)
+}
- return unicastTo(undoActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
- }
+async function sendUndoLike (byActor: ActorModel, video: VideoModel, t: Transaction) {
+ logger.info('Creating job to undo a like of video %s.', video.url)
- const audience = getObjectFollowersAudience(actorsInvolvedInVideo)
- const undoActivity = undoActivityData(undoUrl, byActor, likeActivity, audience)
+ const likeUrl = getVideoLikeActivityPubUrl(byActor, video)
+ const likeActivity = buildLikeActivity(likeUrl, byActor, video)
- const followersException = [ byActor ]
- return broadcastToFollowers(undoActivity, byActor, actorsInvolvedInVideo, t, followersException)
+ return sendUndoVideoRelatedActivity({ byActor, video, url: likeUrl, activity: likeActivity, transaction: t })
}
async function sendUndoDislike (byActor: ActorModel, video: VideoModel, t: Transaction) {
logger.info('Creating job to undo a dislike of video %s.', video.url)
const dislikeUrl = getVideoDislikeActivityPubUrl(byActor, video)
- const undoUrl = getUndoActivityPubUrl(dislikeUrl)
-
- const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
const dislikeActivity = buildDislikeActivity(byActor, video)
const createDislikeActivity = buildCreateActivity(dislikeUrl, byActor, dislikeActivity)
- if (video.isOwned() === false) {
- const audience = getVideoAudience(video, actorsInvolvedInVideo)
- const undoActivity = undoActivityData(undoUrl, byActor, createDislikeActivity, audience)
-
- return unicastTo(undoActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
- }
-
- const undoActivity = undoActivityData(undoUrl, byActor, createDislikeActivity)
-
- const followersException = [ byActor ]
- return broadcastToFollowers(undoActivity, byActor, actorsInvolvedInVideo, t, followersException)
-}
-
-async function sendUndoAnnounce (byActor: ActorModel, videoShare: VideoShareModel, video: VideoModel, t: Transaction) {
- logger.info('Creating job to undo announce %s.', videoShare.url)
-
- const undoUrl = getUndoActivityPubUrl(videoShare.url)
-
- const { activity: announceActivity, actorsInvolvedInVideo } = await buildAnnounceWithVideoAudience(byActor, videoShare, video, t)
- const undoActivity = undoActivityData(undoUrl, byActor, announceActivity)
-
- const followersException = [ byActor ]
- return broadcastToFollowers(undoActivity, byActor, actorsInvolvedInVideo, t, followersException)
+ return sendUndoVideoRelatedActivity({ byActor, video, url: dislikeUrl, activity: createDislikeActivity, transaction: t })
}
async function sendUndoCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel, t: Transaction) {
logger.info('Creating job to undo cache file %s.', redundancyModel.url)
- const undoUrl = getUndoActivityPubUrl(redundancyModel.url)
-
const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(redundancyModel.VideoFile.Video.id)
- const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
-
- const audience = getVideoAudience(video, actorsInvolvedInVideo)
const createActivity = buildCreateActivity(redundancyModel.url, byActor, redundancyModel.toActivityPubObject())
- const undoActivity = undoActivityData(undoUrl, byActor, createActivity, audience)
-
- return unicastTo(undoActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
+ return sendUndoVideoRelatedActivity({ byActor, video, url: redundancyModel.url, activity: createActivity, transaction: t })
}
// ---------------------------------------------------------------------------
audience
)
}
+
+async function sendUndoVideoRelatedActivity (options: {
+ byActor: ActorModel,
+ video: VideoModel,
+ url: string,
+ activity: ActivityFollow | ActivityLike | ActivityCreate | ActivityAnnounce,
+ transaction: Transaction
+}) {
+ const activityBuilder = (audience: ActivityAudience) => {
+ const undoUrl = getUndoActivityPubUrl(options.url)
+
+ return undoActivityData(undoUrl, options.byActor, options.activity, audience)
+ }
+
+ return sendVideoRelatedActivity(activityBuilder, options)
+}
import { VideoChannelModel } from '../../../models/video/video-channel'
import { VideoShareModel } from '../../../models/video/video-share'
import { getUpdateActivityPubUrl } from '../url'
-import { broadcastToFollowers, unicastTo } from './utils'
-import { audiencify, getActorsInvolvedInVideo, getAudience, getObjectFollowersAudience } from '../audience'
+import { broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
+import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf } from '../audience'
import { logger } from '../../../helpers/logger'
import { VideoCaptionModel } from '../../../models/video/video-caption'
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
async function sendUpdateCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel) {
logger.info('Creating job to update cache file %s.', redundancyModel.url)
- const url = getUpdateActivityPubUrl(redundancyModel.url, redundancyModel.updatedAt.toISOString())
const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(redundancyModel.VideoFile.Video.id)
- const redundancyObject = redundancyModel.toActivityPubObject()
+ const activityBuilder = (audience: ActivityAudience) => {
+ const redundancyObject = redundancyModel.toActivityPubObject()
+ const url = getUpdateActivityPubUrl(redundancyModel.url, redundancyModel.updatedAt.toISOString())
- const accountsInvolvedInVideo = await getActorsInvolvedInVideo(video, undefined)
- const audience = getObjectFollowersAudience(accountsInvolvedInVideo)
+ return buildUpdateActivity(url, byActor, redundancyObject, audience)
+ }
- const updateActivity = buildUpdateActivity(url, byActor, redundancyObject, audience)
- return unicastTo(updateActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
+ return sendVideoRelatedActivity(activityBuilder, { byActor, video })
}
// ---------------------------------------------------------------------------
import { Transaction } from 'sequelize'
-import { Activity } from '../../../../shared/models/activitypub'
+import { Activity, ActivityAudience } from '../../../../shared/models/activitypub'
import { logger } from '../../../helpers/logger'
import { ActorModel } from '../../../models/activitypub/actor'
import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
import { JobQueue } from '../../job-queue'
import { VideoModel } from '../../../models/video/video'
-import { getActorsInvolvedInVideo } from '../audience'
+import { getActorsInvolvedInVideo, getAudienceFromFollowersOf, getRemoteVideoAudience } from '../audience'
import { getServerActor } from '../../../helpers/utils'
+async function sendVideoRelatedActivity (activityBuilder: (audience: ActivityAudience) => Activity, options: {
+ byActor: ActorModel,
+ video: VideoModel,
+ transaction?: Transaction
+}) {
+ const actorsInvolvedInVideo = await getActorsInvolvedInVideo(options.video, options.transaction)
+
+ // Send to origin
+ if (options.video.isOwned() === false) {
+ const audience = getRemoteVideoAudience(options.video, actorsInvolvedInVideo)
+ const activity = activityBuilder(audience)
+
+ return unicastTo(activity, options.byActor, options.video.VideoChannel.Account.Actor.sharedInboxUrl)
+ }
+
+ // Send to followers
+ const audience = getAudienceFromFollowersOf(actorsInvolvedInVideo)
+ const activity = activityBuilder(audience)
+
+ const actorsException = [ options.byActor ]
+ return broadcastToFollowers(activity, options.byActor, actorsInvolvedInVideo, options.transaction, actorsException)
+}
+
async function forwardVideoRelatedActivity (
activity: Activity,
t: Transaction,
unicastTo,
forwardActivity,
broadcastToActors,
- forwardVideoRelatedActivity
+ forwardVideoRelatedActivity,
+ sendVideoRelatedActivity
}
// ---------------------------------------------------------------------------
try {
// Maybe it's a reply to a video?
// If yes, it's done: we resolved all the thread
- const { video } = await getOrCreateVideoAndAccountAndChannel(url)
+ const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: url })
if (comments.length !== 0) {
const firstReply = comments[ comments.length - 1 ]
import * as magnetUtil from 'magnet-uri'
import { join } from 'path'
import * as request from 'request'
-import { ActivityIconObject, ActivityVideoUrlObject, VideoState, ActivityUrlObject } from '../../../shared/index'
+import { ActivityIconObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState } from '../../../shared/index'
import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
import { VideoPrivacy } from '../../../shared/models/videos'
import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
import { createRates } from './video-rates'
import { addVideoShares, shareVideoByServerAndChannel } from './share'
import { AccountModel } from '../../models/account/account'
+import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
// If the video is not private and published, we federate it
}
}
-function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
- const host = video.VideoChannel.Account.Actor.Server.host
+async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
+ const options = {
+ uri: videoUrl,
+ method: 'GET',
+ json: true,
+ activityPub: true
+ }
- // We need to provide a callback, if no we could have an uncaught exception
- return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
- if (err) reject(err)
- })
+ logger.info('Fetching remote video %s.', videoUrl)
+
+ const { response, body } = await doRequest(options)
+
+ if (sanitizeAndCheckVideoTorrentObject(body) === false) {
+ logger.debug('Remote video JSON is not valid.', { body })
+ return { response, videoObject: undefined }
+ }
+
+ return { response, videoObject: body }
}
async function fetchRemoteVideoDescription (video: VideoModel) {
const host = video.VideoChannel.Account.Actor.Server.host
- const path = video.getDescriptionPath()
+ const path = video.getDescriptionAPIPath()
const options = {
uri: REMOTE_SCHEME.HTTP + '://' + host + path,
json: true
return body.description ? body.description : ''
}
+function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
+ const host = video.VideoChannel.Account.Actor.Server.host
+
+ // We need to provide a callback, if no we could have an uncaught exception
+ return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
+ if (err) reject(err)
+ })
+}
+
function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
const thumbnailName = video.getThumbnailName()
const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
return doRequestAndSaveToFile(options, thumbnailPath)
}
-async function videoActivityObjectToDBAttributes (
- videoChannel: VideoChannelModel,
- videoObject: VideoTorrentObject,
- to: string[] = []
-) {
- const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
- const duration = videoObject.duration.replace(/[^\d]+/, '')
-
- let language: string | undefined
- if (videoObject.language) {
- language = videoObject.language.identifier
- }
-
- let category: number | undefined
- if (videoObject.category) {
- category = parseInt(videoObject.category.identifier, 10)
- }
-
- let licence: number | undefined
- if (videoObject.licence) {
- licence = parseInt(videoObject.licence.identifier, 10)
- }
-
- const description = videoObject.content || null
- const support = videoObject.support || null
-
- return {
- name: videoObject.name,
- uuid: videoObject.uuid,
- url: videoObject.id,
- category,
- licence,
- language,
- description,
- support,
- nsfw: videoObject.sensitive,
- commentsEnabled: videoObject.commentsEnabled,
- waitTranscoding: videoObject.waitTranscoding,
- state: videoObject.state,
- channelId: videoChannel.id,
- duration: parseInt(duration, 10),
- createdAt: new Date(videoObject.published),
- publishedAt: new Date(videoObject.published),
- // FIXME: updatedAt does not seems to be considered by Sequelize
- updatedAt: new Date(videoObject.updated),
- views: videoObject.views,
- likes: 0,
- dislikes: 0,
- remote: true,
- privacy
- }
-}
-
-function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObject: VideoTorrentObject) {
- const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
-
- if (fileUrls.length === 0) {
- throw new Error('Cannot find video files for ' + videoCreated.url)
- }
-
- const attributes: VideoFileModel[] = []
- for (const fileUrl of fileUrls) {
- // Fetch associated magnet uri
- const magnet = videoObject.url.find(u => {
- return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.height === fileUrl.height
- })
-
- if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
-
- const parsed = magnetUtil.decode(magnet.href)
- if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
- throw new Error('Cannot parse magnet URI ' + magnet.href)
- }
-
- const attribute = {
- extname: VIDEO_MIMETYPE_EXT[ fileUrl.mimeType ],
- infoHash: parsed.infoHash,
- resolution: fileUrl.height,
- size: fileUrl.size,
- videoId: videoCreated.id,
- fps: fileUrl.fps
- } as VideoFileModel
- attributes.push(attribute)
- }
-
- return attributes
-}
-
function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
const channel = videoObject.attributedTo.find(a => a.type === 'Group')
if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
- return getOrCreateActorAndServerAndModel(channel.id)
-}
-
-async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
- logger.debug('Adding remote video %s.', videoObject.id)
-
- const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
- const sequelizeOptions = { transaction: t }
-
- const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
- const video = VideoModel.build(videoData)
-
- const videoCreated = await video.save(sequelizeOptions)
-
- // Process files
- const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
- if (videoFileAttributes.length === 0) {
- throw new Error('Cannot find valid files for video %s ' + videoObject.url)
- }
-
- const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
- await Promise.all(videoFilePromises)
-
- // Process tags
- const tags = videoObject.tag.map(t => t.name)
- const tagInstances = await TagModel.findOrCreateTags(tags, t)
- await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
-
- // Process captions
- const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
- return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
- })
- await Promise.all(videoCaptionsPromises)
-
- logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
-
- videoCreated.VideoChannel = channelActor.VideoChannel
- return videoCreated
- })
-
- const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
- .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
-
- if (waitThumbnail === true) await p
-
- return videoCreated
+ return getOrCreateActorAndServerAndModel(channel.id, 'all')
}
type SyncParam = {
thumbnail: boolean
refreshVideo: boolean
}
-async function getOrCreateVideoAndAccountAndChannel (
- videoObject: VideoTorrentObject | string,
- syncParam: SyncParam = { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
-) {
- const videoUrl = typeof videoObject === 'string' ? videoObject : videoObject.id
-
- let videoFromDatabase = await VideoModel.loadByUrlAndPopulateAccount(videoUrl)
- if (videoFromDatabase) {
- const p = retryTransactionWrapper(refreshVideoIfNeeded, videoFromDatabase)
- if (syncParam.refreshVideo === true) videoFromDatabase = await p
-
- return { video: videoFromDatabase }
- }
-
- const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
- if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
-
- const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
- const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
-
- // Process outside the transaction because we could fetch remote data
-
+async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
const jobPayloads: ActivitypubHttpFetcherPayload[] = []
}
await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
-
- return { video }
}
-async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
- const options = {
- uri: videoUrl,
- method: 'GET',
- json: true,
- activityPub: true
- }
-
- logger.info('Fetching remote video %s.', videoUrl)
-
- const { response, body } = await doRequest(options)
+async function getOrCreateVideoAndAccountAndChannel (options: {
+ videoObject: VideoTorrentObject | string,
+ syncParam?: SyncParam,
+ fetchType?: VideoFetchByUrlType,
+ refreshViews?: boolean
+}) {
+ // Default params
+ const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
+ const fetchType = options.fetchType || 'all'
+ const refreshViews = options.refreshViews || false
+
+ // Get video url
+ const videoUrl = typeof options.videoObject === 'string' ? options.videoObject : options.videoObject.id
+
+ let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
+ if (videoFromDatabase) {
+ const refreshOptions = {
+ video: videoFromDatabase,
+ fetchedType: fetchType,
+ syncParam,
+ refreshViews
+ }
+ const p = retryTransactionWrapper(refreshVideoIfNeeded, refreshOptions)
+ if (syncParam.refreshVideo === true) videoFromDatabase = await p
- if (sanitizeAndCheckVideoTorrentObject(body) === false) {
- logger.debug('Remote video JSON is not valid.', { body })
- return { response, videoObject: undefined }
+ return { video: videoFromDatabase }
}
- return { response, videoObject: body }
-}
-
-async function refreshVideoIfNeeded (video: VideoModel): Promise<VideoModel> {
- if (!video.isOutdated()) return video
-
- try {
- const { response, videoObject } = await fetchRemoteVideo(video.url)
- if (response.statusCode === 404) {
- // Video does not exist anymore
- await video.destroy()
- return undefined
- }
+ const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
+ if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
- if (videoObject === undefined) {
- logger.warn('Cannot refresh remote video: invalid body.')
- return video
- }
+ const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
+ const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
- const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
- const account = await AccountModel.load(channelActor.VideoChannel.accountId)
+ await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
- return updateVideoFromAP(video, videoObject, account, channelActor.VideoChannel)
- } catch (err) {
- logger.warn('Cannot refresh video.', { err })
- return video
- }
+ return { video }
}
-async function updateVideoFromAP (
+async function updateVideoFromAP (options: {
video: VideoModel,
videoObject: VideoTorrentObject,
account: AccountModel,
channel: VideoChannelModel,
+ updateViews: boolean,
overrideTo?: string[]
-) {
- logger.debug('Updating remote video "%s".', videoObject.uuid)
+}) {
+ logger.debug('Updating remote video "%s".', options.videoObject.uuid)
let videoFieldsSave: any
try {
transaction: t
}
- videoFieldsSave = video.toJSON()
+ videoFieldsSave = options.video.toJSON()
// Check actor has the right to update the video
- const videoChannel = video.VideoChannel
- if (videoChannel.Account.id !== account.id) {
- throw new Error('Account ' + account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
+ const videoChannel = options.video.VideoChannel
+ if (videoChannel.Account.id !== options.account.id) {
+ throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
}
- const to = overrideTo ? overrideTo : videoObject.to
- const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, to)
- video.set('name', videoData.name)
- video.set('uuid', videoData.uuid)
- video.set('url', videoData.url)
- video.set('category', videoData.category)
- video.set('licence', videoData.licence)
- video.set('language', videoData.language)
- video.set('description', videoData.description)
- video.set('support', videoData.support)
- video.set('nsfw', videoData.nsfw)
- video.set('commentsEnabled', videoData.commentsEnabled)
- video.set('waitTranscoding', videoData.waitTranscoding)
- video.set('state', videoData.state)
- video.set('duration', videoData.duration)
- video.set('createdAt', videoData.createdAt)
- video.set('publishedAt', videoData.publishedAt)
- video.set('views', videoData.views)
- video.set('privacy', videoData.privacy)
- video.set('channelId', videoData.channelId)
-
- await video.save(sequelizeOptions)
+ const to = options.overrideTo ? options.overrideTo : options.videoObject.to
+ const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
+ options.video.set('name', videoData.name)
+ options.video.set('uuid', videoData.uuid)
+ options.video.set('url', videoData.url)
+ options.video.set('category', videoData.category)
+ options.video.set('licence', videoData.licence)
+ options.video.set('language', videoData.language)
+ options.video.set('description', videoData.description)
+ options.video.set('support', videoData.support)
+ options.video.set('nsfw', videoData.nsfw)
+ options.video.set('commentsEnabled', videoData.commentsEnabled)
+ options.video.set('waitTranscoding', videoData.waitTranscoding)
+ options.video.set('state', videoData.state)
+ options.video.set('duration', videoData.duration)
+ options.video.set('createdAt', videoData.createdAt)
+ options.video.set('publishedAt', videoData.publishedAt)
+ options.video.set('privacy', videoData.privacy)
+ options.video.set('channelId', videoData.channelId)
+
+ if (options.updateViews === true) options.video.set('views', videoData.views)
+ await options.video.save(sequelizeOptions)
// Don't block on request
- generateThumbnailFromUrl(video, videoObject.icon)
- .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
+ generateThumbnailFromUrl(options.video, options.videoObject.icon)
+ .catch(err => logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err }))
// Remove old video files
const videoFileDestroyTasks: Bluebird<void>[] = []
- for (const videoFile of video.VideoFiles) {
+ for (const videoFile of options.video.VideoFiles) {
videoFileDestroyTasks.push(videoFile.destroy(sequelizeOptions))
}
await Promise.all(videoFileDestroyTasks)
- const videoFileAttributes = videoFileActivityUrlToDBAttributes(video, videoObject)
+ const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, sequelizeOptions))
await Promise.all(tasks)
// Update Tags
- const tags = videoObject.tag.map(tag => tag.name)
+ const tags = options.videoObject.tag.map(tag => tag.name)
const tagInstances = await TagModel.findOrCreateTags(tags, t)
- await video.$set('Tags', tagInstances, sequelizeOptions)
+ await options.video.$set('Tags', tagInstances, sequelizeOptions)
// Update captions
- await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(video.id, t)
+ await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
- const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
- return VideoCaptionModel.insertOrReplaceLanguage(video.id, c.identifier, t)
+ const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
+ return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
})
await Promise.all(videoCaptionsPromises)
})
- logger.info('Remote video with uuid %s updated', videoObject.uuid)
+ logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
return updatedVideo
} catch (err) {
- if (video !== undefined && videoFieldsSave !== undefined) {
- resetSequelizeInstance(video, videoFieldsSave)
+ if (options.video !== undefined && videoFieldsSave !== undefined) {
+ resetSequelizeInstance(options.video, videoFieldsSave)
}
// This is just a debug because we will retry the insert
fetchRemoteVideoStaticFile,
fetchRemoteVideoDescription,
generateThumbnailFromUrl,
- videoActivityObjectToDBAttributes,
- videoFileActivityUrlToDBAttributes,
- createVideo,
- getOrCreateVideoChannelFromVideoObject,
- addVideoShares,
- createRates
+ getOrCreateVideoChannelFromVideoObject
}
// ---------------------------------------------------------------------------
return mimeTypes.indexOf(url.mimeType) !== -1 && url.mimeType.startsWith('video/')
}
+
+async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
+ logger.debug('Adding remote video %s.', videoObject.id)
+
+ const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
+ const sequelizeOptions = { transaction: t }
+
+ const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
+ const video = VideoModel.build(videoData)
+
+ const videoCreated = await video.save(sequelizeOptions)
+
+ // Process files
+ const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
+ if (videoFileAttributes.length === 0) {
+ throw new Error('Cannot find valid files for video %s ' + videoObject.url)
+ }
+
+ const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
+ await Promise.all(videoFilePromises)
+
+ // Process tags
+ const tags = videoObject.tag.map(t => t.name)
+ const tagInstances = await TagModel.findOrCreateTags(tags, t)
+ await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
+
+ // Process captions
+ const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
+ return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
+ })
+ await Promise.all(videoCaptionsPromises)
+
+ logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
+
+ videoCreated.VideoChannel = channelActor.VideoChannel
+ return videoCreated
+ })
+
+ const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
+ .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
+
+ if (waitThumbnail === true) await p
+
+ return videoCreated
+}
+
+async function refreshVideoIfNeeded (options: {
+ video: VideoModel,
+ fetchedType: VideoFetchByUrlType,
+ syncParam: SyncParam,
+ refreshViews: boolean
+}): Promise<VideoModel> {
+ if (!options.video.isOutdated()) return options.video
+
+ // We need more attributes if the argument video was fetched with not enough joints
+ const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
+
+ try {
+ const { response, videoObject } = await fetchRemoteVideo(video.url)
+ if (response.statusCode === 404) {
+ // Video does not exist anymore
+ await video.destroy()
+ return undefined
+ }
+
+ if (videoObject === undefined) {
+ logger.warn('Cannot refresh remote video: invalid body.')
+ return video
+ }
+
+ const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
+ const account = await AccountModel.load(channelActor.VideoChannel.accountId)
+
+ const updateOptions = {
+ video,
+ videoObject,
+ account,
+ channel: channelActor.VideoChannel,
+ updateViews: options.refreshViews
+ }
+ await updateVideoFromAP(updateOptions)
+ await syncVideoExternalAttributes(video, videoObject, options.syncParam)
+ } catch (err) {
+ logger.warn('Cannot refresh video.', { err })
+ return video
+ }
+}
+
+async function videoActivityObjectToDBAttributes (
+ videoChannel: VideoChannelModel,
+ videoObject: VideoTorrentObject,
+ to: string[] = []
+) {
+ const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
+ const duration = videoObject.duration.replace(/[^\d]+/, '')
+
+ let language: string | undefined
+ if (videoObject.language) {
+ language = videoObject.language.identifier
+ }
+
+ let category: number | undefined
+ if (videoObject.category) {
+ category = parseInt(videoObject.category.identifier, 10)
+ }
+
+ let licence: number | undefined
+ if (videoObject.licence) {
+ licence = parseInt(videoObject.licence.identifier, 10)
+ }
+
+ const description = videoObject.content || null
+ const support = videoObject.support || null
+
+ return {
+ name: videoObject.name,
+ uuid: videoObject.uuid,
+ url: videoObject.id,
+ category,
+ licence,
+ language,
+ description,
+ support,
+ nsfw: videoObject.sensitive,
+ commentsEnabled: videoObject.commentsEnabled,
+ waitTranscoding: videoObject.waitTranscoding,
+ state: videoObject.state,
+ channelId: videoChannel.id,
+ duration: parseInt(duration, 10),
+ createdAt: new Date(videoObject.published),
+ publishedAt: new Date(videoObject.published),
+ // FIXME: updatedAt does not seems to be considered by Sequelize
+ updatedAt: new Date(videoObject.updated),
+ views: videoObject.views,
+ likes: 0,
+ dislikes: 0,
+ remote: true,
+ privacy
+ }
+}
+
+function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObject: VideoTorrentObject) {
+ const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
+
+ if (fileUrls.length === 0) {
+ throw new Error('Cannot find video files for ' + videoCreated.url)
+ }
+
+ const attributes: VideoFileModel[] = []
+ for (const fileUrl of fileUrls) {
+ // Fetch associated magnet uri
+ const magnet = videoObject.url.find(u => {
+ return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.height === fileUrl.height
+ })
+
+ if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
+
+ const parsed = magnetUtil.decode(magnet.href)
+ if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
+ throw new Error('Cannot parse magnet URI ' + magnet.href)
+ }
+
+ const attribute = {
+ extname: VIDEO_MIMETYPE_EXT[ fileUrl.mimeType ],
+ infoHash: parsed.infoHash,
+ resolution: fileUrl.height,
+ size: fileUrl.size,
+ videoId: videoCreated.id,
+ fps: fileUrl.fps
+ } as VideoFileModel
+ attributes.push(attribute)
+ }
+
+ return attributes
+}
import { AVATARS_SIZE, CONFIG, sequelizeTypescript } from '../initializers'
import { updateActorAvatarInstance } from './activitypub'
import { processImage } from '../helpers/image-utils'
-import { ActorModel } from '../models/activitypub/actor'
import { AccountModel } from '../models/account/account'
import { VideoChannelModel } from '../models/video/video-channel'
import { extname, join } from 'path'
-async function updateActorAvatarFile (
- avatarPhysicalFile: Express.Multer.File,
- actor: ActorModel,
- accountOrChannel: AccountModel | VideoChannelModel
-) {
+async function updateActorAvatarFile (avatarPhysicalFile: Express.Multer.File, accountOrChannel: AccountModel | VideoChannelModel) {
const extension = extname(avatarPhysicalFile.filename)
- const avatarName = actor.uuid + extension
+ const avatarName = accountOrChannel.Actor.uuid + extension
const destination = join(CONFIG.STORAGE.AVATARS_DIR, avatarName)
await processImage(avatarPhysicalFile, destination, AVATARS_SIZE)
return sequelizeTypescript.transaction(async t => {
- const updatedActor = await updateActorAvatarInstance(actor, avatarName, t)
+ const updatedActor = await updateActorAvatarInstance(accountOrChannel.Actor, avatarName, t)
await updatedActor.save({ transaction: t })
await sendUpdateActor(accountOrChannel, t)
if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.')
// Used to fetch the path
- const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(videoId)
+ const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
if (!video) return undefined
const remoteStaticPath = videoCaption.getCaptionStaticPath()
}
async getFilePath (videoUUID: string) {
- const video = await VideoModel.loadByUUID(videoUUID)
+ const video = await VideoModel.loadByUUIDWithFile(videoUUID)
if (!video) return undefined
if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName())
}
protected async loadRemoteFile (key: string) {
- const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(key)
+ const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(key)
if (!video) return undefined
if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.')
import * as validator from 'validator'
import { VideoPrivacy } from '../../shared/models/videos'
import { readFile } from 'fs-extra'
+import { getActivityStreamDuration } from '../models/video/video-format-utils'
export class ClientHtml {
let videoPromise: Bluebird<VideoModel>
// Let Angular application handle errors
- if (validator.isUUID(videoId, 4)) {
- videoPromise = VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(videoId)
- } else if (validator.isInt(videoId)) {
- videoPromise = VideoModel.loadAndPopulateAccountAndServerAndTags(+videoId)
+ if (validator.isInt(videoId) || validator.isUUID(videoId, 4)) {
+ videoPromise = VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
} else {
return ClientHtml.getIndexHTML(req, res)
}
description: videoDescriptionEscaped,
thumbnailUrl: previewUrl,
uploadDate: video.createdAt.toISOString(),
- duration: video.getActivityStreamDuration(),
+ duration: getActivityStreamDuration(video.duration),
contentUrl: videoUrl,
embedUrl: embedUrl,
interactionCount: video.views
import * as Bull from 'bull'
import { logger } from '../../../helpers/logger'
import { processActivities } from '../../activitypub/process'
-import { VideoModel } from '../../../models/video/video'
-import { addVideoShares, createRates } from '../../activitypub/videos'
import { addVideoComments } from '../../activitypub/video-comments'
import { crawlCollectionPage } from '../../activitypub/crawl'
+import { VideoModel } from '../../../models/video/video'
+import { addVideoShares, createRates } from '../../activitypub'
type FetchType = 'activity' | 'video-likes' | 'video-dislikes' | 'video-shares' | 'video-comments'
import { sequelizeTypescript } from '../../../initializers'
import * as Bluebird from 'bluebird'
import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
+import { importVideoFile, transcodeOriginalVideofile, optimizeOriginalVideofile } from '../../video-transcoding'
export type VideoFilePayload = {
videoUUID: string
const payload = job.data as VideoFileImportPayload
logger.info('Processing video file import in job %d.', job.id)
- const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(payload.videoUUID)
+ const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID)
// No video, maybe deleted?
if (!video) {
logger.info('Do not process job %d, video does not exist.', job.id)
return undefined
}
- await video.importVideoFile(payload.filePath)
+ await importVideoFile(video, payload.filePath)
await onVideoFileTranscoderOrImportSuccess(video)
return video
const payload = job.data as VideoFilePayload
logger.info('Processing video file in job %d.', job.id)
- const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(payload.videoUUID)
+ const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID)
// No video, maybe deleted?
if (!video) {
logger.info('Do not process job %d, video does not exist.', job.id)
// Transcoding in other resolution
if (payload.resolution) {
- await video.transcodeOriginalVideofile(payload.resolution, payload.isPortraitMode || false)
+ await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false)
await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video)
} else {
- await video.optimizeOriginalVideofile()
+ await optimizeOriginalVideofile(video)
await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload.isNewVideo)
}
return sequelizeTypescript.transaction(async t => {
// Maybe the video changed in database, refresh it
- let videoDatabase = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(video.uuid, t)
+ let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
// Video does not exist anymore
if (!videoDatabase) return undefined
return sequelizeTypescript.transaction(async t => {
// Maybe the video changed in database, refresh it
- const videoDatabase = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(video.uuid, t)
+ const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
// Video does not exist anymore
if (!videoDatabase) return undefined
const videoUpdated = await video.save({ transaction: t })
// Now we can federate the video (reload from database, we need more attributes)
- const videoForFederation = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(video.uuid, t)
+ const videoForFederation = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
await federateVideoIfNeeded(videoForFederation, true, t)
// Update video import object
import { OAuthClientModel } from '../models/oauth/oauth-client'
import { OAuthTokenModel } from '../models/oauth/oauth-token'
import { CONFIG } from '../initializers/constants'
+import { Transaction } from 'sequelize'
type TokenInfo = { accessToken: string, refreshToken: string, accessTokenExpiresAt: Date, refreshTokenExpiresAt: Date }
+const accessTokenCache: { [ accessToken: string ]: OAuthTokenModel } = {}
+const userHavingToken: { [ userId: number ]: string } = {}
// ---------------------------------------------------------------------------
+function deleteUserToken (userId: number, t?: Transaction) {
+ clearCacheByUserId(userId)
+
+ return OAuthTokenModel.deleteUserToken(userId, t)
+}
+
+function clearCacheByUserId (userId: number) {
+ const token = userHavingToken[userId]
+ if (token !== undefined) {
+ accessTokenCache[ token ] = undefined
+ userHavingToken[ userId ] = undefined
+ }
+}
+
+function clearCacheByToken (token: string) {
+ const tokenModel = accessTokenCache[ token ]
+ if (tokenModel !== undefined) {
+ userHavingToken[tokenModel.userId] = undefined
+ accessTokenCache[ token ] = undefined
+ }
+}
+
function getAccessToken (bearerToken: string) {
logger.debug('Getting access token (bearerToken: ' + bearerToken + ').')
+ if (accessTokenCache[bearerToken] !== undefined) return accessTokenCache[bearerToken]
+
return OAuthTokenModel.getByTokenAndPopulateUser(bearerToken)
+ .then(tokenModel => {
+ if (tokenModel) {
+ accessTokenCache[ bearerToken ] = tokenModel
+ userHavingToken[ tokenModel.userId ] = tokenModel.accessToken
+ }
+
+ return tokenModel
+ })
}
function getClient (clientId: string, clientSecret: string) {
async function revokeToken (tokenInfo: TokenInfo) {
const token = await OAuthTokenModel.getByRefreshTokenAndPopulateUser(tokenInfo.refreshToken)
if (token) {
+ clearCacheByToken(token.accessToken)
+
token.destroy()
.catch(err => logger.error('Cannot destroy token when revoking token.', { err }))
}
// See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
export {
+ deleteUserToken,
+ clearCacheByUserId,
+ clearCacheByToken,
getAccessToken,
getClient,
getRefreshToken,
import { AbstractScheduler } from './abstract-scheduler'
import { CONFIG, JOB_TTL, REDUNDANCY, SCHEDULER_INTERVALS_MS } from '../../initializers'
import { logger } from '../../helpers/logger'
-import { VideoRedundancyStrategy } from '../../../shared/models/redundancy'
+import { VideoRedundancyStrategy, VideosRedundancy } from '../../../shared/models/redundancy'
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
import { VideoFileModel } from '../../models/video/video-file'
-import { sortBy } from 'lodash'
import { downloadWebTorrentVideo } from '../../helpers/webtorrent'
import { join } from 'path'
import { rename } from 'fs-extra'
import { sendCreateCacheFile, sendUpdateCacheFile } from '../activitypub/send'
import { VideoModel } from '../../models/video/video'
import { getVideoCacheFileActivityPubUrl } from '../activitypub/url'
-import { removeVideoRedundancy } from '../redundancy'
import { isTestInstance } from '../../helpers/core-utils'
export class VideosRedundancyScheduler extends AbstractScheduler {
private static instance: AbstractScheduler
private executing = false
- protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.videosRedundancy
+ protected schedulerIntervalMs = CONFIG.REDUNDANCY.VIDEOS.CHECK_INTERVAL
private constructor () {
super()
this.executing = true
- for (const obj of CONFIG.REDUNDANCY.VIDEOS) {
-
+ for (const obj of CONFIG.REDUNDANCY.VIDEOS.STRATEGIES) {
try {
- const videoToDuplicate = await this.findVideoToDuplicate(obj.strategy)
+ const videoToDuplicate = await this.findVideoToDuplicate(obj)
if (!videoToDuplicate) continue
const videoFiles = videoToDuplicate.VideoFiles
videoFiles.forEach(f => f.Video = videoToDuplicate)
- const videosRedundancy = await VideoRedundancyModel.getVideoFiles(obj.strategy)
- if (this.isTooHeavy(videosRedundancy, videoFiles, obj.size)) {
+ if (await this.isTooHeavy(obj.strategy, videoFiles, obj.size)) {
if (!isTestInstance()) logger.info('Video %s is too big for our cache, skipping.', videoToDuplicate.url)
continue
}
}
}
+ await this.removeExpired()
+
+ this.executing = false
+ }
+
+ static get Instance () {
+ return this.instance || (this.instance = new this())
+ }
+
+ private async removeExpired () {
const expired = await VideoRedundancyModel.listAllExpired()
for (const m of expired) {
logger.error('Cannot remove %s video from our redundancy system.', this.buildEntryLogId(m))
}
}
-
- this.executing = false
}
- static get Instance () {
- return this.instance || (this.instance = new this())
- }
+ private findVideoToDuplicate (cache: VideosRedundancy) {
+ if (cache.strategy === 'most-views') {
+ return VideoRedundancyModel.findMostViewToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR)
+ }
+
+ if (cache.strategy === 'trending') {
+ return VideoRedundancyModel.findTrendingToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR)
+ }
- private findVideoToDuplicate (strategy: VideoRedundancyStrategy) {
- if (strategy === 'most-views') return VideoRedundancyModel.findMostViewToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR)
+ if (cache.strategy === 'recently-added') {
+ const minViews = cache.minViews
+ return VideoRedundancyModel.findRecentlyAddedToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR, minViews)
+ }
}
private async createVideoRedundancy (strategy: VideoRedundancyStrategy, filesToDuplicate: VideoFileModel[]) {
}
}
- // Unused, but could be useful in the future, with a custom strategy
- private async purgeVideosIfNeeded (videosRedundancy: VideoRedundancyModel[], filesToDuplicate: VideoFileModel[], maxSize: number) {
- const sortedVideosRedundancy = sortBy(videosRedundancy, 'createdAt')
-
- while (this.isTooHeavy(sortedVideosRedundancy, filesToDuplicate, maxSize)) {
- const toDelete = sortedVideosRedundancy.shift()
-
- const videoFile = toDelete.VideoFile
- logger.info('Purging video %s (resolution %d) from our redundancy system.', videoFile.Video.url, videoFile.resolution)
-
- await removeVideoRedundancy(toDelete, undefined)
- }
-
- return sortedVideosRedundancy
- }
-
- private isTooHeavy (videosRedundancy: VideoRedundancyModel[], filesToDuplicate: VideoFileModel[], maxSizeArg: number) {
+ private async isTooHeavy (strategy: VideoRedundancyStrategy, filesToDuplicate: VideoFileModel[], maxSizeArg: number) {
const maxSize = maxSizeArg - this.getTotalFileSizes(filesToDuplicate)
- const redundancyReducer = (previous: number, current: VideoRedundancyModel) => previous + current.VideoFile.size
- const totalDuplicated = videosRedundancy.reduce(redundancyReducer, 0)
+ const totalDuplicated = await VideoRedundancyModel.getTotalDuplicated(strategy)
return totalDuplicated > maxSize
}
-// Thanks: https://github.com/przemyslawpluta/node-youtube-dl/blob/master/lib/downloader.js
-// We rewrote it to avoid sync calls
-
import { AbstractScheduler } from './abstract-scheduler'
import { SCHEDULER_INTERVALS_MS } from '../../initializers'
-import { logger } from '../../helpers/logger'
-import * as request from 'request'
-import { createWriteStream, ensureDir, writeFile } from 'fs-extra'
-import { join } from 'path'
-import { root } from '../../helpers/core-utils'
+import { updateYoutubeDLBinary } from '../../helpers/youtube-dl'
export class YoutubeDlUpdateScheduler extends AbstractScheduler {
super()
}
- async execute () {
- logger.info('Updating youtubeDL binary.')
-
- const binDirectory = join(root(), 'node_modules', 'youtube-dl', 'bin')
- const bin = join(binDirectory, 'youtube-dl')
- const detailsPath = join(binDirectory, 'details')
- const url = 'https://yt-dl.org/downloads/latest/youtube-dl'
-
- await ensureDir(binDirectory)
-
- return new Promise(res => {
- request.get(url, { followRedirect: false }, (err, result) => {
- if (err) {
- logger.error('Cannot update youtube-dl.', { err })
- return res()
- }
-
- if (result.statusCode !== 302) {
- logger.error('youtube-dl update error: did not get redirect for the latest version link. Status %d', result.statusCode)
- return res()
- }
-
- const url = result.headers.location
- const downloadFile = request.get(url)
- const newVersion = /yt-dl\.org\/downloads\/(\d{4}\.\d\d\.\d\d(\.\d)?)\/youtube-dl/.exec(url)[ 1 ]
-
- downloadFile.on('response', result => {
- if (result.statusCode !== 200) {
- logger.error('Cannot update youtube-dl: new version response is not 200, it\'s %d.', result.statusCode)
- return res()
- }
-
- downloadFile.pipe(createWriteStream(bin, { mode: 493 }))
- })
-
- downloadFile.on('error', err => {
- logger.error('youtube-dl update error.', { err })
- return res()
- })
-
- downloadFile.on('end', () => {
- const details = JSON.stringify({ version: newVersion, path: bin, exec: 'youtube-dl' })
- writeFile(detailsPath, details, { encoding: 'utf8' }, err => {
- if (err) {
- logger.error('youtube-dl update error: cannot write details.', { err })
- return res()
- }
-
- logger.info('youtube-dl updated to version %s.', newVersion)
- return res()
- })
- })
- })
- })
+ execute () {
+ return updateYoutubeDLBinary()
}
static get Instance () {
--- /dev/null
+import { CONFIG } from '../initializers'
+import { join, extname } from 'path'
+import { getVideoFileFPS, getVideoFileResolution, transcode } from '../helpers/ffmpeg-utils'
+import { copy, remove, rename, stat } from 'fs-extra'
+import { logger } from '../helpers/logger'
+import { VideoResolution } from '../../shared/models/videos'
+import { VideoFileModel } from '../models/video/video-file'
+import { VideoModel } from '../models/video/video'
+
+async function optimizeOriginalVideofile (video: VideoModel) {
+ const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
+ const newExtname = '.mp4'
+ const inputVideoFile = video.getOriginalFile()
+ const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile))
+ const videoTranscodedPath = join(videosDirectory, video.id + '-transcoded' + newExtname)
+
+ const transcodeOptions = {
+ inputPath: videoInputPath,
+ outputPath: videoTranscodedPath
+ }
+
+ // Could be very long!
+ await transcode(transcodeOptions)
+
+ try {
+ await remove(videoInputPath)
+
+ // Important to do this before getVideoFilename() to take in account the new file extension
+ inputVideoFile.set('extname', newExtname)
+
+ const videoOutputPath = video.getVideoFilePath(inputVideoFile)
+ await rename(videoTranscodedPath, videoOutputPath)
+ const stats = await stat(videoOutputPath)
+ const fps = await getVideoFileFPS(videoOutputPath)
+
+ inputVideoFile.set('size', stats.size)
+ inputVideoFile.set('fps', fps)
+
+ await video.createTorrentAndSetInfoHash(inputVideoFile)
+ await inputVideoFile.save()
+ } catch (err) {
+ // Auto destruction...
+ video.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
+
+ throw err
+ }
+}
+
+async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) {
+ const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
+ const extname = '.mp4'
+
+ // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
+ const videoInputPath = join(videosDirectory, video.getVideoFilename(video.getOriginalFile()))
+
+ const newVideoFile = new VideoFileModel({
+ resolution,
+ extname,
+ size: 0,
+ videoId: video.id
+ })
+ const videoOutputPath = join(videosDirectory, video.getVideoFilename(newVideoFile))
+
+ const transcodeOptions = {
+ inputPath: videoInputPath,
+ outputPath: videoOutputPath,
+ resolution,
+ isPortraitMode
+ }
+
+ await transcode(transcodeOptions)
+
+ const stats = await stat(videoOutputPath)
+ const fps = await getVideoFileFPS(videoOutputPath)
+
+ newVideoFile.set('size', stats.size)
+ newVideoFile.set('fps', fps)
+
+ await video.createTorrentAndSetInfoHash(newVideoFile)
+
+ await newVideoFile.save()
+
+ video.VideoFiles.push(newVideoFile)
+}
+
+async function importVideoFile (video: VideoModel, inputFilePath: string) {
+ const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
+ const { size } = await stat(inputFilePath)
+ const fps = await getVideoFileFPS(inputFilePath)
+
+ let updatedVideoFile = new VideoFileModel({
+ resolution: videoFileResolution,
+ extname: extname(inputFilePath),
+ size,
+ fps,
+ videoId: video.id
+ })
+
+ const currentVideoFile = video.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution)
+
+ if (currentVideoFile) {
+ // Remove old file and old torrent
+ await video.removeFile(currentVideoFile)
+ await video.removeTorrent(currentVideoFile)
+ // Remove the old video file from the array
+ video.VideoFiles = video.VideoFiles.filter(f => f !== currentVideoFile)
+
+ // Update the database
+ currentVideoFile.set('extname', updatedVideoFile.extname)
+ currentVideoFile.set('size', updatedVideoFile.size)
+ currentVideoFile.set('fps', updatedVideoFile.fps)
+
+ updatedVideoFile = currentVideoFile
+ }
+
+ const outputPath = video.getVideoFilePath(updatedVideoFile)
+ await copy(inputFilePath, outputPath)
+
+ await video.createTorrentAndSetInfoHash(updatedVideoFile)
+
+ await updatedVideoFile.save()
+
+ video.VideoFiles.push(updatedVideoFile)
+}
+
+export {
+ optimizeOriginalVideofile,
+ transcodeOriginalVideofile,
+ importVideoFile
+}
logger.debug('Checking usersVideoRating parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
- if (!await isVideoExist(req.params.videoId, res)) return
+ if (!await isVideoExist(req.params.videoId, res, 'id')) return
return next()
}
logger.debug('Checking listVideoCaptions parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
- if (!await isVideoExist(req.params.videoId, res)) return
+ if (!await isVideoExist(req.params.videoId, res, 'id')) return
return next()
}
logger.debug('Checking listVideoCommentThreads parameters.', { parameters: req.params })
if (areValidationErrors(req, res)) return
- if (!await isVideoExist(req.params.videoId, res)) return
+ if (!await isVideoExist(req.params.videoId, res, 'only-video')) return
return next()
}
logger.debug('Checking listVideoThreadComments parameters.', { parameters: req.params })
if (areValidationErrors(req, res)) return
- if (!await isVideoExist(req.params.videoId, res)) return
+ if (!await isVideoExist(req.params.videoId, res, 'only-video')) return
if (!await isVideoCommentThreadExist(req.params.threadId, res.locals.video, res)) return
return next()
logger.debug('Checking videoCommentGetValidator parameters.', { parameters: req.params })
if (areValidationErrors(req, res)) return
- if (!await isVideoExist(req.params.videoId, res)) return
+ if (!await isVideoExist(req.params.videoId, res, 'id')) return
if (!await isVideoCommentExist(req.params.commentId, res.locals.video, res)) return
return next()
import { VideoChangeOwnershipAccept } from '../../../shared/models/videos/video-change-ownership-accept.model'
import { VideoChangeOwnershipModel } from '../../models/video/video-change-ownership'
import { AccountModel } from '../../models/account/account'
+import { VideoFetchType } from '../../helpers/video'
const videosAddValidator = getCommonVideoAttributes().concat([
body('videofile')
}
])
-const videosGetValidator = [
- param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
-
- async (req: express.Request, res: express.Response, next: express.NextFunction) => {
- logger.debug('Checking videosGet parameters', { parameters: req.params })
+const videosCustomGetValidator = (fetchType: VideoFetchType) => {
+ return [
+ param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
- if (areValidationErrors(req, res)) return
- if (!await isVideoExist(req.params.id, res)) return
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking videosGet parameters', { parameters: req.params })
- const video: VideoModel = res.locals.video
+ if (areValidationErrors(req, res)) return
+ if (!await isVideoExist(req.params.id, res, fetchType)) return
- // Video private or blacklisted
- if (video.privacy === VideoPrivacy.PRIVATE || video.VideoBlacklist) {
- return authenticate(req, res, () => {
- const user: UserModel = res.locals.oauth.token.User
+ const video: VideoModel = res.locals.video
- // Only the owner or a user that have blacklist rights can see the video
- if (video.VideoChannel.Account.userId !== user.id && !user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) {
- return res.status(403)
- .json({ error: 'Cannot get this private or blacklisted video.' })
- .end()
- }
+ // Video private or blacklisted
+ if (video.privacy === VideoPrivacy.PRIVATE || video.VideoBlacklist) {
+ return authenticate(req, res, () => {
+ const user: UserModel = res.locals.oauth.token.User
- return next()
- })
+ // Only the owner or a user that have blacklist rights can see the video
+ if (video.VideoChannel.Account.userId !== user.id && !user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) {
+ return res.status(403)
+ .json({ error: 'Cannot get this private or blacklisted video.' })
+ .end()
+ }
- return
- }
+ return next()
+ })
+ }
- // Video is public, anyone can access it
- if (video.privacy === VideoPrivacy.PUBLIC) return next()
+ // Video is public, anyone can access it
+ if (video.privacy === VideoPrivacy.PUBLIC) return next()
- // Video is unlisted, check we used the uuid to fetch it
- if (video.privacy === VideoPrivacy.UNLISTED) {
- if (isUUIDValid(req.params.id)) return next()
+ // Video is unlisted, check we used the uuid to fetch it
+ if (video.privacy === VideoPrivacy.UNLISTED) {
+ if (isUUIDValid(req.params.id)) return next()
- // Don't leak this unlisted video
- return res.status(404).end()
+ // Don't leak this unlisted video
+ return res.status(404).end()
+ }
}
- }
-]
+ ]
+}
+
+const videosGetValidator = videosCustomGetValidator('all')
const videosRemoveValidator = [
param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
videosAddValidator,
videosUpdateValidator,
videosGetValidator,
+ videosCustomGetValidator,
videosRemoveValidator,
videosShareValidator,
return undefined
}
- static load (id: number) {
- return AccountModel.findById(id)
+ static load (id: number, transaction?: Sequelize.Transaction) {
+ return AccountModel.findById(id, { transaction })
}
static loadByUUID (uuid: string) {
import * as Sequelize from 'sequelize'
import {
+ AfterDelete,
+ AfterUpdate,
AllowNull,
BeforeCreate,
BeforeUpdate,
import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
import { values } from 'lodash'
import { NSFW_POLICY_TYPES } from '../../initializers'
+import { clearCacheByUserId } from '../../lib/oauth-model'
enum ScopeNames {
WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
}
}
+ @AfterUpdate
+ @AfterDelete
+ static removeTokenCache (instance: UserModel) {
+ return clearCacheByUserId(instance.id)
+ }
+
static countTotal () {
return this.count()
}
return ActorModel.unscoped().findById(id)
}
+ static isActorUrlExist (url: string) {
+ const query = {
+ raw: true,
+ where: {
+ url
+ }
+ }
+
+ return ActorModel.unscoped().findOne(query)
+ .then(a => !!a)
+ }
+
static listByFollowersUrls (followersUrls: string[], transaction?: Sequelize.Transaction) {
const query = {
where: {
}
static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
+ const query = {
+ where: {
+ url
+ },
+ transaction,
+ include: [
+ {
+ attributes: [ 'id' ],
+ model: AccountModel.unscoped(),
+ required: false
+ },
+ {
+ attributes: [ 'id' ],
+ model: VideoChannelModel.unscoped(),
+ required: false
+ }
+ ]
+ }
+
+ return ActorModel.unscoped().findOne(query)
+ }
+
+ static loadByUrlAndPopulateAccountAndChannel (url: string, transaction?: Sequelize.Transaction) {
const query = {
where: {
url
-import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
+import {
+ AfterDelete,
+ AfterUpdate,
+ AllowNull,
+ BelongsTo,
+ Column,
+ CreatedAt,
+ ForeignKey,
+ Model,
+ Scopes,
+ Table,
+ UpdatedAt
+} from 'sequelize-typescript'
import { logger } from '../../helpers/logger'
-import { AccountModel } from '../account/account'
import { UserModel } from '../account/user'
import { OAuthClientModel } from './oauth-client'
import { Transaction } from 'sequelize'
+import { AccountModel } from '../account/account'
+import { ActorModel } from '../activitypub/actor'
+import { clearCacheByToken } from '../../lib/oauth-model'
export type OAuthTokenInfo = {
refreshToken: string
}
enum ScopeNames {
- WITH_ACCOUNT = 'WITH_ACCOUNT'
+ WITH_USER = 'WITH_USER'
}
@Scopes({
- [ScopeNames.WITH_ACCOUNT]: {
+ [ScopeNames.WITH_USER]: {
include: [
{
- model: () => UserModel,
+ model: () => UserModel.unscoped(),
+ required: true,
include: [
{
- model: () => AccountModel,
- required: true
+ attributes: [ 'id' ],
+ model: () => AccountModel.unscoped(),
+ required: true,
+ include: [
+ {
+ attributes: [ 'id' ],
+ model: () => ActorModel.unscoped(),
+ required: true
+ }
+ ]
}
]
}
})
OAuthClients: OAuthClientModel[]
+ @AfterUpdate
+ @AfterDelete
+ static removeTokenCache (token: OAuthTokenModel) {
+ return clearCacheByToken(token.accessToken)
+ }
+
static getByRefreshTokenAndPopulateClient (refreshToken: string) {
const query = {
where: {
}
}
- return OAuthTokenModel.scope(ScopeNames.WITH_ACCOUNT).findOne(query).then(token => {
+ return OAuthTokenModel.scope(ScopeNames.WITH_USER).findOne(query).then(token => {
if (token) token['user'] = token.User
return token
}
}
- return OAuthTokenModel.scope(ScopeNames.WITH_ACCOUNT)
+ return OAuthTokenModel.scope(ScopeNames.WITH_USER)
.findOne(query)
.then(token => {
if (token) {
UpdatedAt
} from 'sequelize-typescript'
import { ActorModel } from '../activitypub/actor'
-import { throwIfNotValid } from '../utils'
+import { getVideoSort, throwIfNotValid } from '../utils'
import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc'
-import { CONSTRAINTS_FIELDS, VIDEO_EXT_MIMETYPE } from '../../initializers'
+import { CONFIG, CONSTRAINTS_FIELDS, VIDEO_EXT_MIMETYPE } from '../../initializers'
import { VideoFileModel } from '../video/video-file'
-import { isDateValid } from '../../helpers/custom-validators/misc'
import { getServerActor } from '../../helpers/utils'
import { VideoModel } from '../video/video'
import { VideoRedundancyStrategy } from '../../../shared/models/redundancy'
import { ServerModel } from '../server/server'
import { sample } from 'lodash'
import { isTestInstance } from '../../helpers/core-utils'
+import * as Bluebird from 'bluebird'
export enum ScopeNames {
WITH_VIDEO = 'WITH_VIDEO'
return VideoRedundancyModel.findOne(query)
}
+ static async getVideoSample (p: Bluebird<VideoModel[]>) {
+ const rows = await p
+ const ids = rows.map(r => r.id)
+ const id = sample(ids)
+
+ return VideoModel.loadWithFile(id, undefined, !isTestInstance())
+ }
+
static async findMostViewToDuplicate (randomizedFactor: number) {
// On VideoModel!
const query = {
+ attributes: [ 'id', 'views' ],
logging: !isTestInstance(),
limit: randomizedFactor,
- order: [ [ 'views', 'DESC' ] ],
+ order: getVideoSort('-views'),
include: [
- {
- model: VideoFileModel.unscoped(),
- required: true,
- where: {
- id: {
- [ Sequelize.Op.notIn ]: await VideoRedundancyModel.buildExcludeIn()
- }
- }
- },
- {
- attributes: [],
- model: VideoChannelModel.unscoped(),
- required: true,
- include: [
- {
- attributes: [],
- model: ActorModel.unscoped(),
- required: true,
- include: [
- {
- attributes: [],
- model: ServerModel.unscoped(),
- required: true,
- where: {
- redundancyAllowed: true
- }
- }
- ]
- }
- ]
- }
+ await VideoRedundancyModel.buildVideoFileForDuplication(),
+ VideoRedundancyModel.buildServerRedundancyInclude()
]
}
- const rows = await VideoModel.unscoped().findAll(query)
+ return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
+ }
- return sample(rows)
+ static async findTrendingToDuplicate (randomizedFactor: number) {
+ // On VideoModel!
+ const query = {
+ attributes: [ 'id', 'views' ],
+ subQuery: false,
+ logging: !isTestInstance(),
+ group: 'VideoModel.id',
+ limit: randomizedFactor,
+ order: getVideoSort('-trending'),
+ include: [
+ await VideoRedundancyModel.buildVideoFileForDuplication(),
+ VideoRedundancyModel.buildServerRedundancyInclude(),
+
+ VideoModel.buildTrendingQuery(CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS)
+ ]
+ }
+
+ return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
}
- static async getVideoFiles (strategy: VideoRedundancyStrategy) {
+ static async findRecentlyAddedToDuplicate (randomizedFactor: number, minViews: number) {
+ // On VideoModel!
+ const query = {
+ attributes: [ 'id', 'publishedAt' ],
+ logging: !isTestInstance(),
+ limit: randomizedFactor,
+ order: getVideoSort('-publishedAt'),
+ where: {
+ views: {
+ [ Sequelize.Op.gte ]: minViews
+ }
+ },
+ include: [
+ await VideoRedundancyModel.buildVideoFileForDuplication(),
+ VideoRedundancyModel.buildServerRedundancyInclude()
+ ]
+ }
+
+ return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
+ }
+
+ static async getTotalDuplicated (strategy: VideoRedundancyStrategy) {
const actor = await getServerActor()
- const queryVideoFiles = {
+ const options = {
logging: !isTestInstance(),
- where: {
- actorId: actor.id,
- strategy
- }
+ include: [
+ {
+ attributes: [],
+ model: VideoRedundancyModel,
+ required: true,
+ where: {
+ actorId: actor.id,
+ strategy
+ }
+ }
+ ]
}
- return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO)
- .findAll(queryVideoFiles)
+ return VideoFileModel.sum('size', options)
}
static listAllExpired () {
logging: !isTestInstance(),
where: {
expiresOn: {
- [Sequelize.Op.lt]: new Date()
+ [ Sequelize.Op.lt ]: new Date()
}
}
}
.findAll(query)
}
+ static async getStats (strategy: VideoRedundancyStrategy) {
+ const actor = await getServerActor()
+
+ const query = {
+ raw: true,
+ attributes: [
+ [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoFile.size')), '0'), 'totalUsed' ],
+ [ Sequelize.fn('COUNT', Sequelize.fn('DISTINCT', 'videoId')), 'totalVideos' ],
+ [ Sequelize.fn('COUNT', 'videoFileId'), 'totalVideoFiles' ]
+ ],
+ where: {
+ strategy,
+ actorId: actor.id
+ },
+ include: [
+ {
+ attributes: [],
+ model: VideoFileModel,
+ required: true
+ }
+ ]
+ }
+
+ return VideoRedundancyModel.find(query as any) // FIXME: typings
+ .then((r: any) => ({
+ totalUsed: parseInt(r.totalUsed.toString(), 10),
+ totalVideos: r.totalVideos,
+ totalVideoFiles: r.totalVideoFiles
+ }))
+ }
+
toActivityPubObject (): CacheFileObject {
return {
id: this.url,
}
}
- private static async buildExcludeIn () {
+ // Don't include video files we already duplicated
+ private static async buildVideoFileForDuplication () {
const actor = await getServerActor()
- return Sequelize.literal(
+ const notIn = Sequelize.literal(
'(' +
`SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id} AND "expiresOn" >= NOW()` +
')'
)
+
+ return {
+ attributes: [],
+ model: VideoFileModel.unscoped(),
+ required: true,
+ where: {
+ id: {
+ [ Sequelize.Op.notIn ]: notIn
+ }
+ }
+ }
+ }
+
+ private static buildServerRedundancyInclude () {
+ return {
+ attributes: [],
+ model: VideoChannelModel.unscoped(),
+ required: true,
+ include: [
+ {
+ attributes: [],
+ model: ActorModel.unscoped(),
+ required: true,
+ include: [
+ {
+ attributes: [],
+ model: ServerModel.unscoped(),
+ required: true,
+ where: {
+ redundancyAllowed: true
+ }
+ }
+ ]
+ }
+ ]
+ }
}
}
},
defaults: {
name: tag
- }
+ },
+ transaction
}
- if (transaction) query['transaction'] = transaction
-
const promise = TagModel.findOrCreate(query)
.then(([ tagInstance ]) => tagInstance)
tasks.push(promise)
--- /dev/null
+import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
+import { VideoModel } from './video'
+import { VideoFileModel } from './video-file'
+import { ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects'
+import { CONFIG, THUMBNAILS_SIZE, VIDEO_EXT_MIMETYPE } from '../../initializers'
+import { VideoCaptionModel } from './video-caption'
+import {
+ getVideoCommentsActivityPubUrl,
+ getVideoDislikesActivityPubUrl,
+ getVideoLikesActivityPubUrl,
+ getVideoSharesActivityPubUrl
+} from '../../lib/activitypub'
+
+export type VideoFormattingJSONOptions = {
+ additionalAttributes: {
+ state?: boolean,
+ waitTranscoding?: boolean,
+ scheduledUpdate?: boolean,
+ blacklistInfo?: boolean
+ }
+}
+function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
+ const formattedAccount = video.VideoChannel.Account.toFormattedJSON()
+ const formattedVideoChannel = video.VideoChannel.toFormattedJSON()
+
+ const videoObject: Video = {
+ id: video.id,
+ uuid: video.uuid,
+ name: video.name,
+ category: {
+ id: video.category,
+ label: VideoModel.getCategoryLabel(video.category)
+ },
+ licence: {
+ id: video.licence,
+ label: VideoModel.getLicenceLabel(video.licence)
+ },
+ language: {
+ id: video.language,
+ label: VideoModel.getLanguageLabel(video.language)
+ },
+ privacy: {
+ id: video.privacy,
+ label: VideoModel.getPrivacyLabel(video.privacy)
+ },
+ nsfw: video.nsfw,
+ description: video.getTruncatedDescription(),
+ isLocal: video.isOwned(),
+ duration: video.duration,
+ views: video.views,
+ likes: video.likes,
+ dislikes: video.dislikes,
+ thumbnailPath: video.getThumbnailStaticPath(),
+ previewPath: video.getPreviewStaticPath(),
+ embedPath: video.getEmbedStaticPath(),
+ createdAt: video.createdAt,
+ updatedAt: video.updatedAt,
+ publishedAt: video.publishedAt,
+ account: {
+ id: formattedAccount.id,
+ uuid: formattedAccount.uuid,
+ name: formattedAccount.name,
+ displayName: formattedAccount.displayName,
+ url: formattedAccount.url,
+ host: formattedAccount.host,
+ avatar: formattedAccount.avatar
+ },
+ channel: {
+ id: formattedVideoChannel.id,
+ uuid: formattedVideoChannel.uuid,
+ name: formattedVideoChannel.name,
+ displayName: formattedVideoChannel.displayName,
+ url: formattedVideoChannel.url,
+ host: formattedVideoChannel.host,
+ avatar: formattedVideoChannel.avatar
+ }
+ }
+
+ if (options) {
+ if (options.additionalAttributes.state === true) {
+ videoObject.state = {
+ id: video.state,
+ label: VideoModel.getStateLabel(video.state)
+ }
+ }
+
+ if (options.additionalAttributes.waitTranscoding === true) {
+ videoObject.waitTranscoding = video.waitTranscoding
+ }
+
+ if (options.additionalAttributes.scheduledUpdate === true && video.ScheduleVideoUpdate) {
+ videoObject.scheduledUpdate = {
+ updateAt: video.ScheduleVideoUpdate.updateAt,
+ privacy: video.ScheduleVideoUpdate.privacy || undefined
+ }
+ }
+
+ if (options.additionalAttributes.blacklistInfo === true) {
+ videoObject.blacklisted = !!video.VideoBlacklist
+ videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
+ }
+ }
+
+ return videoObject
+}
+
+function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
+ const formattedJson = video.toFormattedJSON({
+ additionalAttributes: {
+ scheduledUpdate: true,
+ blacklistInfo: true
+ }
+ })
+
+ const tags = video.Tags ? video.Tags.map(t => t.name) : []
+ const detailsJson = {
+ support: video.support,
+ descriptionPath: video.getDescriptionAPIPath(),
+ channel: video.VideoChannel.toFormattedJSON(),
+ account: video.VideoChannel.Account.toFormattedJSON(),
+ tags,
+ commentsEnabled: video.commentsEnabled,
+ waitTranscoding: video.waitTranscoding,
+ state: {
+ id: video.state,
+ label: VideoModel.getStateLabel(video.state)
+ },
+ files: []
+ }
+
+ // Format and sort video files
+ detailsJson.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
+
+ return Object.assign(formattedJson, detailsJson)
+}
+
+function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
+ const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
+
+ return videoFiles
+ .map(videoFile => {
+ let resolutionLabel = videoFile.resolution + 'p'
+
+ return {
+ resolution: {
+ id: videoFile.resolution,
+ label: resolutionLabel
+ },
+ magnetUri: video.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
+ size: videoFile.size,
+ fps: videoFile.fps,
+ torrentUrl: video.getTorrentUrl(videoFile, baseUrlHttp),
+ torrentDownloadUrl: video.getTorrentDownloadUrl(videoFile, baseUrlHttp),
+ fileUrl: video.getVideoFileUrl(videoFile, baseUrlHttp),
+ fileDownloadUrl: video.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
+ } as VideoFile
+ })
+ .sort((a, b) => {
+ if (a.resolution.id < b.resolution.id) return 1
+ if (a.resolution.id === b.resolution.id) return 0
+ return -1
+ })
+}
+
+function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
+ const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
+ if (!video.Tags) video.Tags = []
+
+ const tag = video.Tags.map(t => ({
+ type: 'Hashtag' as 'Hashtag',
+ name: t.name
+ }))
+
+ let language
+ if (video.language) {
+ language = {
+ identifier: video.language,
+ name: VideoModel.getLanguageLabel(video.language)
+ }
+ }
+
+ let category
+ if (video.category) {
+ category = {
+ identifier: video.category + '',
+ name: VideoModel.getCategoryLabel(video.category)
+ }
+ }
+
+ let licence
+ if (video.licence) {
+ licence = {
+ identifier: video.licence + '',
+ name: VideoModel.getLicenceLabel(video.licence)
+ }
+ }
+
+ const url: ActivityUrlObject[] = []
+ for (const file of video.VideoFiles) {
+ url.push({
+ type: 'Link',
+ mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any,
+ href: video.getVideoFileUrl(file, baseUrlHttp),
+ height: file.resolution,
+ size: file.size,
+ fps: file.fps
+ })
+
+ url.push({
+ type: 'Link',
+ mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
+ href: video.getTorrentUrl(file, baseUrlHttp),
+ height: file.resolution
+ })
+
+ url.push({
+ type: 'Link',
+ mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
+ href: video.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
+ height: file.resolution
+ })
+ }
+
+ // Add video url too
+ url.push({
+ type: 'Link',
+ mimeType: 'text/html',
+ href: CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
+ })
+
+ const subtitleLanguage = []
+ for (const caption of video.VideoCaptions) {
+ subtitleLanguage.push({
+ identifier: caption.language,
+ name: VideoCaptionModel.getLanguageLabel(caption.language)
+ })
+ }
+
+ return {
+ type: 'Video' as 'Video',
+ id: video.url,
+ name: video.name,
+ duration: getActivityStreamDuration(video.duration),
+ uuid: video.uuid,
+ tag,
+ category,
+ licence,
+ language,
+ views: video.views,
+ sensitive: video.nsfw,
+ waitTranscoding: video.waitTranscoding,
+ state: video.state,
+ commentsEnabled: video.commentsEnabled,
+ published: video.publishedAt.toISOString(),
+ updated: video.updatedAt.toISOString(),
+ mediaType: 'text/markdown',
+ content: video.getTruncatedDescription(),
+ support: video.support,
+ subtitleLanguage,
+ icon: {
+ type: 'Image',
+ url: video.getThumbnailUrl(baseUrlHttp),
+ mediaType: 'image/jpeg',
+ width: THUMBNAILS_SIZE.width,
+ height: THUMBNAILS_SIZE.height
+ },
+ url,
+ likes: getVideoLikesActivityPubUrl(video),
+ dislikes: getVideoDislikesActivityPubUrl(video),
+ shares: getVideoSharesActivityPubUrl(video),
+ comments: getVideoCommentsActivityPubUrl(video),
+ attributedTo: [
+ {
+ type: 'Person',
+ id: video.VideoChannel.Account.Actor.url
+ },
+ {
+ type: 'Group',
+ id: video.VideoChannel.Actor.url
+ }
+ ]
+ }
+}
+
+function getActivityStreamDuration (duration: number) {
+ // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
+ return 'PT' + duration + 'S'
+}
+
+export {
+ videoModelToFormattedJSON,
+ videoModelToFormattedDetailsJSON,
+ videoFilesModelToFormattedJSON,
+ videoModelToActivityPubObject,
+ getActivityStreamDuration
+}
import * as Bluebird from 'bluebird'
-import { map, maxBy } from 'lodash'
+import { maxBy } from 'lodash'
import * as magnetUtil from 'magnet-uri'
import * as parseTorrent from 'parse-torrent'
-import { extname, join } from 'path'
+import { join } from 'path'
import * as Sequelize from 'sequelize'
import {
AllowNull,
Table,
UpdatedAt
} from 'sequelize-typescript'
-import { ActivityUrlObject, VideoPrivacy, VideoResolution, VideoState } from '../../../shared'
+import { VideoPrivacy, VideoState } from '../../../shared'
import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
import { VideoFilter } from '../../../shared/models/videos/video-query.type'
isVideoStateValid,
isVideoSupportValid
} from '../../helpers/custom-validators/videos'
-import { generateImageFromVideoFile, getVideoFileFPS, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils'
+import { generateImageFromVideoFile, getVideoFileResolution } from '../../helpers/ffmpeg-utils'
import { logger } from '../../helpers/logger'
import { getServerActor } from '../../helpers/utils'
import {
STATIC_PATHS,
THUMBNAILS_SIZE,
VIDEO_CATEGORIES,
- VIDEO_EXT_MIMETYPE,
VIDEO_LANGUAGES,
VIDEO_LICENCES,
VIDEO_PRIVACIES,
VIDEO_STATES
} from '../../initializers'
-import {
- getVideoCommentsActivityPubUrl,
- getVideoDislikesActivityPubUrl,
- getVideoLikesActivityPubUrl,
- getVideoSharesActivityPubUrl
-} from '../../lib/activitypub'
import { sendDeleteVideo } from '../../lib/activitypub/send'
import { AccountModel } from '../account/account'
import { AccountVideoRateModel } from '../account/account-video-rate'
import { ScheduleVideoUpdateModel } from './schedule-video-update'
import { VideoCaptionModel } from './video-caption'
import { VideoBlacklistModel } from './video-blacklist'
-import { copy, remove, rename, stat, writeFile } from 'fs-extra'
+import { remove, writeFile } from 'fs-extra'
import { VideoViewModel } from './video-views'
import { VideoRedundancyModel } from '../redundancy/video-redundancy'
+import {
+ videoFilesModelToFormattedJSON,
+ VideoFormattingJSONOptions,
+ videoModelToActivityPubObject,
+ videoModelToFormattedDetailsJSON,
+ videoModelToFormattedJSON
+} from './video-format-utils'
+import * as validator from 'validator'
// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
const indexes: Sequelize.DefineIndexesOptions[] = [
},
[ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
const query: IFindOptions<VideoModel> = {
+ raw: true,
attributes: [ 'id' ],
where: {
id: {
}
if (options.trendingDays) {
- query.include.push({
- attributes: [],
- model: VideoViewModel,
- required: false,
- where: {
- startDate: {
- [ Sequelize.Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * options.trendingDays)
- }
- }
- })
+ query.include.push(VideoModel.buildTrendingQuery(options.trendingDays))
query.subQuery = false
}
required: false,
include: [
{
+ attributes: [ 'fileUrl' ],
model: () => VideoRedundancyModel.unscoped(),
required: false
}
videoChannelId?: number,
actorId?: number
trendingDays?: number
- }) {
+ }, countVideos = true) {
const query: IFindOptions<VideoModel> = {
offset: options.start,
limit: options.count,
trendingDays
}
- return VideoModel.getAvailableForApi(query, queryOptions)
+ return VideoModel.getAvailableForApi(query, queryOptions, countVideos)
}
static async searchAndPopulateAccountAndServer (options: {
return VideoModel.getAvailableForApi(query, queryOptions)
}
- static load (id: number, t?: Sequelize.Transaction) {
- const options = t ? { transaction: t } : undefined
-
- return VideoModel.findById(id, options)
- }
-
- static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
- const query: IFindOptions<VideoModel> = {
- where: {
- url
- }
+ static load (id: number | string, t?: Sequelize.Transaction) {
+ const where = VideoModel.buildWhereIdOrUUID(id)
+ const options = {
+ where,
+ transaction: t
}
- if (t !== undefined) query.transaction = t
-
- return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
+ return VideoModel.findOne(options)
}
- static loadAndPopulateAccountAndServerAndTags (id: number) {
+ static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
+ const where = VideoModel.buildWhereIdOrUUID(id)
+
const options = {
- order: [ [ 'Tags', 'name', 'ASC' ] ]
+ attributes: [ 'id' ],
+ where,
+ transaction: t
}
- return VideoModel
- .scope([
- ScopeNames.WITH_TAGS,
- ScopeNames.WITH_BLACKLISTED,
- ScopeNames.WITH_FILES,
- ScopeNames.WITH_ACCOUNT_DETAILS,
- ScopeNames.WITH_SCHEDULED_UPDATE
- ])
- .findById(id, options)
+ return VideoModel.findOne(options)
}
- static loadByUUID (uuid: string) {
+ static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) {
+ return VideoModel.scope(ScopeNames.WITH_FILES)
+ .findById(id, { transaction: t, logging })
+ }
+
+ static loadByUUIDWithFile (uuid: string) {
const options = {
where: {
uuid
.findOne(options)
}
- static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string, t?: Sequelize.Transaction) {
- const options = {
- order: [ [ 'Tags', 'name', 'ASC' ] ],
+ static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
+ const query: IFindOptions<VideoModel> = {
where: {
- uuid
+ url
+ },
+ transaction
+ }
+
+ return VideoModel.findOne(query)
+ }
+
+ static loadByUrlAndPopulateAccount (url: string, transaction?: Sequelize.Transaction) {
+ const query: IFindOptions<VideoModel> = {
+ where: {
+ url
},
+ transaction
+ }
+
+ return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
+ }
+
+ static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction) {
+ const where = VideoModel.buildWhereIdOrUUID(id)
+
+ const options = {
+ order: [ [ 'Tags', 'name', 'ASC' ] ],
+ where,
transaction: t
}
}
// threshold corresponds to how many video the field should have to be returned
- static getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
+ static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
+ const actorId = (await getServerActor()).id
+
+ const scopeOptions = {
+ actorId,
+ includeLocalVideos: true
+ }
+
const query: IFindOptions<VideoModel> = {
attributes: [ field ],
limit: count,
having: Sequelize.where(Sequelize.fn('COUNT', Sequelize.col(field)), {
[ Sequelize.Op.gte ]: threshold
}) as any, // FIXME: typings
- where: {
- [ field ]: {
- [ Sequelize.Op.not ]: null
- },
- privacy: VideoPrivacy.PUBLIC,
- state: VideoState.PUBLISHED
- },
order: [ this.sequelize.random() ]
}
- return VideoModel.findAll(query)
+ return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] })
+ .findAll(query)
.then(rows => rows.map(r => r[ field ]))
}
+ static buildTrendingQuery (trendingDays: number) {
+ return {
+ attributes: [],
+ subQuery: false,
+ model: VideoViewModel,
+ required: false,
+ where: {
+ startDate: {
+ [ Sequelize.Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
+ }
+ }
+ }
+ }
+
private static buildActorWhereWithFilter (filter?: VideoFilter) {
if (filter && filter === 'local') {
return {
return {}
}
- private static async getAvailableForApi (query: IFindOptions<VideoModel>, options: AvailableForListIDsOptions) {
+ private static async getAvailableForApi (query: IFindOptions<VideoModel>, options: AvailableForListIDsOptions, countVideos = true) {
const idsScope = {
method: [
ScopeNames.AVAILABLE_FOR_LIST_IDS, options
}
const [ count, rowsId ] = await Promise.all([
- VideoModel.scope(countScope).count(countQuery),
+ countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve(undefined),
VideoModel.scope(idsScope).findAll(query)
])
const ids = rowsId.map(r => r.id)
}
}
- private static getCategoryLabel (id: number) {
+ static getCategoryLabel (id: number) {
return VIDEO_CATEGORIES[ id ] || 'Misc'
}
- private static getLicenceLabel (id: number) {
+ static getLicenceLabel (id: number) {
return VIDEO_LICENCES[ id ] || 'Unknown'
}
- private static getLanguageLabel (id: string) {
+ static getLanguageLabel (id: string) {
return VIDEO_LANGUAGES[ id ] || 'Unknown'
}
- private static getPrivacyLabel (id: number) {
+ static getPrivacyLabel (id: number) {
return VIDEO_PRIVACIES[ id ] || 'Unknown'
}
- private static getStateLabel (id: number) {
+ static getStateLabel (id: number) {
return VIDEO_STATES[ id ] || 'Unknown'
}
+ static buildWhereIdOrUUID (id: number | string) {
+ return validator.isInt('' + id) ? { id } : { uuid: id }
+ }
+
getOriginalFile () {
if (Array.isArray(this.VideoFiles) === false) return undefined
return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
}
- toFormattedJSON (options?: {
- additionalAttributes: {
- state?: boolean,
- waitTranscoding?: boolean,
- scheduledUpdate?: boolean,
- blacklistInfo?: boolean
- }
- }): Video {
- const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
- const formattedVideoChannel = this.VideoChannel.toFormattedJSON()
-
- const videoObject: Video = {
- id: this.id,
- uuid: this.uuid,
- name: this.name,
- category: {
- id: this.category,
- label: VideoModel.getCategoryLabel(this.category)
- },
- licence: {
- id: this.licence,
- label: VideoModel.getLicenceLabel(this.licence)
- },
- language: {
- id: this.language,
- label: VideoModel.getLanguageLabel(this.language)
- },
- privacy: {
- id: this.privacy,
- label: VideoModel.getPrivacyLabel(this.privacy)
- },
- nsfw: this.nsfw,
- description: this.getTruncatedDescription(),
- isLocal: this.isOwned(),
- duration: this.duration,
- views: this.views,
- likes: this.likes,
- dislikes: this.dislikes,
- thumbnailPath: this.getThumbnailStaticPath(),
- previewPath: this.getPreviewStaticPath(),
- embedPath: this.getEmbedStaticPath(),
- createdAt: this.createdAt,
- updatedAt: this.updatedAt,
- publishedAt: this.publishedAt,
- account: {
- id: formattedAccount.id,
- uuid: formattedAccount.uuid,
- name: formattedAccount.name,
- displayName: formattedAccount.displayName,
- url: formattedAccount.url,
- host: formattedAccount.host,
- avatar: formattedAccount.avatar
- },
- channel: {
- id: formattedVideoChannel.id,
- uuid: formattedVideoChannel.uuid,
- name: formattedVideoChannel.name,
- displayName: formattedVideoChannel.displayName,
- url: formattedVideoChannel.url,
- host: formattedVideoChannel.host,
- avatar: formattedVideoChannel.avatar
- }
- }
-
- if (options) {
- if (options.additionalAttributes.state === true) {
- videoObject.state = {
- id: this.state,
- label: VideoModel.getStateLabel(this.state)
- }
- }
-
- if (options.additionalAttributes.waitTranscoding === true) {
- videoObject.waitTranscoding = this.waitTranscoding
- }
-
- if (options.additionalAttributes.scheduledUpdate === true && this.ScheduleVideoUpdate) {
- videoObject.scheduledUpdate = {
- updateAt: this.ScheduleVideoUpdate.updateAt,
- privacy: this.ScheduleVideoUpdate.privacy || undefined
- }
- }
-
- if (options.additionalAttributes.blacklistInfo === true) {
- videoObject.blacklisted = !!this.VideoBlacklist
- videoObject.blacklistedReason = this.VideoBlacklist ? this.VideoBlacklist.reason : null
- }
- }
-
- return videoObject
+ toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
+ return videoModelToFormattedJSON(this, options)
}
toFormattedDetailsJSON (): VideoDetails {
- const formattedJson = this.toFormattedJSON({
- additionalAttributes: {
- scheduledUpdate: true,
- blacklistInfo: true
- }
- })
-
- const detailsJson = {
- support: this.support,
- descriptionPath: this.getDescriptionPath(),
- channel: this.VideoChannel.toFormattedJSON(),
- account: this.VideoChannel.Account.toFormattedJSON(),
- tags: map(this.Tags, 'name'),
- commentsEnabled: this.commentsEnabled,
- waitTranscoding: this.waitTranscoding,
- state: {
- id: this.state,
- label: VideoModel.getStateLabel(this.state)
- },
- files: []
- }
-
- // Format and sort video files
- detailsJson.files = this.getFormattedVideoFilesJSON()
-
- return Object.assign(formattedJson, detailsJson)
+ return videoModelToFormattedDetailsJSON(this)
}
getFormattedVideoFilesJSON (): VideoFile[] {
- const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
-
- return this.VideoFiles
- .map(videoFile => {
- let resolutionLabel = videoFile.resolution + 'p'
-
- return {
- resolution: {
- id: videoFile.resolution,
- label: resolutionLabel
- },
- magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
- size: videoFile.size,
- fps: videoFile.fps,
- torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
- torrentDownloadUrl: this.getTorrentDownloadUrl(videoFile, baseUrlHttp),
- fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp),
- fileDownloadUrl: this.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
- } as VideoFile
- })
- .sort((a, b) => {
- if (a.resolution.id < b.resolution.id) return 1
- if (a.resolution.id === b.resolution.id) return 0
- return -1
- })
+ return videoFilesModelToFormattedJSON(this, this.VideoFiles)
}
toActivityPubObject (): VideoTorrentObject {
- const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
- if (!this.Tags) this.Tags = []
-
- const tag = this.Tags.map(t => ({
- type: 'Hashtag' as 'Hashtag',
- name: t.name
- }))
-
- let language
- if (this.language) {
- language = {
- identifier: this.language,
- name: VideoModel.getLanguageLabel(this.language)
- }
- }
-
- let category
- if (this.category) {
- category = {
- identifier: this.category + '',
- name: VideoModel.getCategoryLabel(this.category)
- }
- }
-
- let licence
- if (this.licence) {
- licence = {
- identifier: this.licence + '',
- name: VideoModel.getLicenceLabel(this.licence)
- }
- }
-
- const url: ActivityUrlObject[] = []
- for (const file of this.VideoFiles) {
- url.push({
- type: 'Link',
- mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any,
- href: this.getVideoFileUrl(file, baseUrlHttp),
- height: file.resolution,
- size: file.size,
- fps: file.fps
- })
-
- url.push({
- type: 'Link',
- mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
- href: this.getTorrentUrl(file, baseUrlHttp),
- height: file.resolution
- })
-
- url.push({
- type: 'Link',
- mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
- href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
- height: file.resolution
- })
- }
-
- // Add video url too
- url.push({
- type: 'Link',
- mimeType: 'text/html',
- href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
- })
-
- const subtitleLanguage = []
- for (const caption of this.VideoCaptions) {
- subtitleLanguage.push({
- identifier: caption.language,
- name: VideoCaptionModel.getLanguageLabel(caption.language)
- })
- }
-
- return {
- type: 'Video' as 'Video',
- id: this.url,
- name: this.name,
- duration: this.getActivityStreamDuration(),
- uuid: this.uuid,
- tag,
- category,
- licence,
- language,
- views: this.views,
- sensitive: this.nsfw,
- waitTranscoding: this.waitTranscoding,
- state: this.state,
- commentsEnabled: this.commentsEnabled,
- published: this.publishedAt.toISOString(),
- updated: this.updatedAt.toISOString(),
- mediaType: 'text/markdown',
- content: this.getTruncatedDescription(),
- support: this.support,
- subtitleLanguage,
- icon: {
- type: 'Image',
- url: this.getThumbnailUrl(baseUrlHttp),
- mediaType: 'image/jpeg',
- width: THUMBNAILS_SIZE.width,
- height: THUMBNAILS_SIZE.height
- },
- url,
- likes: getVideoLikesActivityPubUrl(this),
- dislikes: getVideoDislikesActivityPubUrl(this),
- shares: getVideoSharesActivityPubUrl(this),
- comments: getVideoCommentsActivityPubUrl(this),
- attributedTo: [
- {
- type: 'Person',
- id: this.VideoChannel.Account.Actor.url
- },
- {
- type: 'Group',
- id: this.VideoChannel.Actor.url
- }
- ]
- }
+ return videoModelToActivityPubObject(this)
}
getTruncatedDescription () {
return peertubeTruncate(this.description, maxLength)
}
- async optimizeOriginalVideofile () {
- const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
- const newExtname = '.mp4'
- const inputVideoFile = this.getOriginalFile()
- const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
- const videoTranscodedPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
-
- const transcodeOptions = {
- inputPath: videoInputPath,
- outputPath: videoTranscodedPath
- }
-
- // Could be very long!
- await transcode(transcodeOptions)
-
- try {
- await remove(videoInputPath)
-
- // Important to do this before getVideoFilename() to take in account the new file extension
- inputVideoFile.set('extname', newExtname)
-
- const videoOutputPath = this.getVideoFilePath(inputVideoFile)
- await rename(videoTranscodedPath, videoOutputPath)
- const stats = await stat(videoOutputPath)
- const fps = await getVideoFileFPS(videoOutputPath)
-
- inputVideoFile.set('size', stats.size)
- inputVideoFile.set('fps', fps)
-
- await this.createTorrentAndSetInfoHash(inputVideoFile)
- await inputVideoFile.save()
-
- } catch (err) {
- // Auto destruction...
- this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
-
- throw err
- }
- }
-
- async transcodeOriginalVideofile (resolution: VideoResolution, isPortraitMode: boolean) {
- const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
- const extname = '.mp4'
-
- // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
- const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
-
- const newVideoFile = new VideoFileModel({
- resolution,
- extname,
- size: 0,
- videoId: this.id
- })
- const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
-
- const transcodeOptions = {
- inputPath: videoInputPath,
- outputPath: videoOutputPath,
- resolution,
- isPortraitMode
- }
-
- await transcode(transcodeOptions)
-
- const stats = await stat(videoOutputPath)
- const fps = await getVideoFileFPS(videoOutputPath)
-
- newVideoFile.set('size', stats.size)
- newVideoFile.set('fps', fps)
-
- await this.createTorrentAndSetInfoHash(newVideoFile)
-
- await newVideoFile.save()
-
- this.VideoFiles.push(newVideoFile)
- }
-
- async importVideoFile (inputFilePath: string) {
- const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
- const { size } = await stat(inputFilePath)
- const fps = await getVideoFileFPS(inputFilePath)
-
- let updatedVideoFile = new VideoFileModel({
- resolution: videoFileResolution,
- extname: extname(inputFilePath),
- size,
- fps,
- videoId: this.id
- })
-
- const currentVideoFile = this.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution)
-
- if (currentVideoFile) {
- // Remove old file and old torrent
- await this.removeFile(currentVideoFile)
- await this.removeTorrent(currentVideoFile)
- // Remove the old video file from the array
- this.VideoFiles = this.VideoFiles.filter(f => f !== currentVideoFile)
-
- // Update the database
- currentVideoFile.set('extname', updatedVideoFile.extname)
- currentVideoFile.set('size', updatedVideoFile.size)
- currentVideoFile.set('fps', updatedVideoFile.fps)
-
- updatedVideoFile = currentVideoFile
- }
-
- const outputPath = this.getVideoFilePath(updatedVideoFile)
- await copy(inputFilePath, outputPath)
-
- await this.createTorrentAndSetInfoHash(updatedVideoFile)
-
- await updatedVideoFile.save()
-
- this.VideoFiles.push(updatedVideoFile)
- }
-
getOriginalFileResolution () {
const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
return getVideoFileResolution(originalFilePath)
}
- getDescriptionPath () {
+ getDescriptionAPIPath () {
return `/api/${API_VERSION}/videos/${this.uuid}/description`
}
.catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
}
- getActivityStreamDuration () {
- // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
- return 'PT' + this.duration + 'S'
- }
-
isOutdated () {
if (this.isOwned()) return false
expect(res.body.total).to.be.above(2)
expect(res.body.data).to.have.lengthOf(1)
- const job = res.body.data[0]
+ let job = res.body.data[0]
+ // Skip repeat jobs
+ if (job.type === 'videos-views') job = res.body.data[1]
expect(job.state).to.equal('completed')
expect(job.type).to.equal('activitypub-follow')
import {
doubleFollow,
flushAndRunMultipleServers,
- flushTests,
getFollowingListPaginationAndSort,
getVideo,
+ immutableAssign,
killallServers,
+ root,
ServerInfo,
setAccessTokensToServers,
uploadVideo,
- wait,
- root, viewVideo
+ viewVideo,
+ wait
} from '../../utils'
import { waitJobs } from '../../utils/server/jobs'
import * as magnetUtil from 'magnet-uri'
import { ActorFollow } from '../../../../shared/models/actors'
import { readdir } from 'fs-extra'
import { join } from 'path'
+import { VideoRedundancyStrategy } from '../../../../shared/models/redundancy'
+import { getStats } from '../../utils/server/stats'
+import { ServerStats } from '../../../../shared/models/server/server-stats.model'
const expect = chai.expect
+let servers: ServerInfo[] = []
+let video1Server2UUID: string
+let video2Server2UUID: string
+
function checkMagnetWebseeds (file: { magnetUri: string, resolution: { id: number } }, baseWebseeds: string[]) {
const parsed = magnetUtil.decode(file.magnetUri)
}
}
-describe('Test videos redundancy', function () {
- let servers: ServerInfo[] = []
- let video1Server2UUID: string
- let video2Server2UUID: string
-
- before(async function () {
- this.timeout(120000)
-
- servers = await flushAndRunMultipleServers(3)
+async function runServers (strategy: VideoRedundancyStrategy, additionalParams: any = {}) {
+ const config = {
+ redundancy: {
+ videos: {
+ check_interval: '5 seconds',
+ strategies: [
+ immutableAssign({
+ strategy: strategy,
+ size: '100KB'
+ }, additionalParams)
+ ]
+ }
+ }
+ }
+ servers = await flushAndRunMultipleServers(3, config)
- // Get the access tokens
- await setAccessTokensToServers(servers)
+ // Get the access tokens
+ await setAccessTokensToServers(servers)
- {
- const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 1 server 2' })
- video1Server2UUID = res.body.video.uuid
+ {
+ const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 1 server 2' })
+ video1Server2UUID = res.body.video.uuid
- await viewVideo(servers[1].url, video1Server2UUID)
- }
+ await viewVideo(servers[ 1 ].url, video1Server2UUID)
+ }
- {
- const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 2 server 2' })
- video2Server2UUID = res.body.video.uuid
- }
+ {
+ const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 2 server 2' })
+ video2Server2UUID = res.body.video.uuid
+ }
- await waitJobs(servers)
+ await waitJobs(servers)
- // Server 1 and server 2 follow each other
- await doubleFollow(servers[0], servers[1])
- // Server 1 and server 3 follow each other
- await doubleFollow(servers[0], servers[2])
- // Server 2 and server 3 follow each other
- await doubleFollow(servers[1], servers[2])
+ // Server 1 and server 2 follow each other
+ await doubleFollow(servers[ 0 ], servers[ 1 ])
+ // Server 1 and server 3 follow each other
+ await doubleFollow(servers[ 0 ], servers[ 2 ])
+ // Server 2 and server 3 follow each other
+ await doubleFollow(servers[ 1 ], servers[ 2 ])
- await waitJobs(servers)
- })
+ await waitJobs(servers)
+}
- it('Should have 1 webseed on the first video', async function () {
- const webseeds = [
- 'http://localhost:9002/static/webseed/' + video1Server2UUID
- ]
+async function check1WebSeed (strategy: VideoRedundancyStrategy) {
+ const webseeds = [
+ 'http://localhost:9002/static/webseed/' + video1Server2UUID
+ ]
- for (const server of servers) {
+ for (const server of servers) {
+ {
const res = await getVideo(server.url, video1Server2UUID)
const video: VideoDetails = res.body
video.files.forEach(f => checkMagnetWebseeds(f, webseeds))
}
- })
- it('Should enable redundancy on server 1', async function () {
- await updateRedundancy(servers[0].url, servers[0].accessToken, servers[1].host, true)
+ {
+ const res = await getStats(server.url)
+ const data: ServerStats = res.body
- const res = await getFollowingListPaginationAndSort(servers[0].url, 0, 5, '-createdAt')
- const follows: ActorFollow[] = res.body.data
- const server2 = follows.find(f => f.following.host === 'localhost:9002')
- const server3 = follows.find(f => f.following.host === 'localhost:9003')
+ expect(data.videosRedundancy).to.have.lengthOf(1)
- expect(server3).to.not.be.undefined
- expect(server3.following.hostRedundancyAllowed).to.be.false
+ const stat = data.videosRedundancy[0]
+ expect(stat.strategy).to.equal(strategy)
+ expect(stat.totalSize).to.equal(102400)
+ expect(stat.totalUsed).to.equal(0)
+ expect(stat.totalVideoFiles).to.equal(0)
+ expect(stat.totalVideos).to.equal(0)
+ }
+ }
+}
- expect(server2).to.not.be.undefined
- expect(server2.following.hostRedundancyAllowed).to.be.true
- })
+async function enableRedundancy () {
+ await updateRedundancy(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ].host, true)
- it('Should have 2 webseed on the first video', async function () {
- this.timeout(40000)
+ const res = await getFollowingListPaginationAndSort(servers[ 0 ].url, 0, 5, '-createdAt')
+ const follows: ActorFollow[] = res.body.data
+ const server2 = follows.find(f => f.following.host === 'localhost:9002')
+ const server3 = follows.find(f => f.following.host === 'localhost:9003')
- await waitJobs(servers)
- await wait(15000)
- await waitJobs(servers)
+ expect(server3).to.not.be.undefined
+ expect(server3.following.hostRedundancyAllowed).to.be.false
- const webseeds = [
- 'http://localhost:9001/static/webseed/' + video1Server2UUID,
- 'http://localhost:9002/static/webseed/' + video1Server2UUID
- ]
+ expect(server2).to.not.be.undefined
+ expect(server2.following.hostRedundancyAllowed).to.be.true
+}
+
+async function check2Webseeds (strategy: VideoRedundancyStrategy) {
+ await waitJobs(servers)
+ await wait(15000)
+ await waitJobs(servers)
- for (const server of servers) {
+ const webseeds = [
+ 'http://localhost:9001/static/webseed/' + video1Server2UUID,
+ 'http://localhost:9002/static/webseed/' + video1Server2UUID
+ ]
+
+ for (const server of servers) {
+ {
const res = await getVideo(server.url, video1Server2UUID)
const video: VideoDetails = res.body
checkMagnetWebseeds(file, webseeds)
}
}
+ }
- const files = await readdir(join(root(), 'test1', 'videos'))
- expect(files).to.have.lengthOf(4)
+ const files = await readdir(join(root(), 'test1', 'videos'))
+ expect(files).to.have.lengthOf(4)
- for (const resolution of [ 240, 360, 480, 720 ]) {
- expect(files.find(f => f === `${video1Server2UUID}-${resolution}.mp4`)).to.not.be.undefined
- }
+ for (const resolution of [ 240, 360, 480, 720 ]) {
+ expect(files.find(f => f === `${video1Server2UUID}-${resolution}.mp4`)).to.not.be.undefined
+ }
+
+ {
+ const res = await getStats(servers[0].url)
+ const data: ServerStats = res.body
+
+ expect(data.videosRedundancy).to.have.lengthOf(1)
+ const stat = data.videosRedundancy[0]
+
+ expect(stat.strategy).to.equal(strategy)
+ expect(stat.totalSize).to.equal(102400)
+ expect(stat.totalUsed).to.be.at.least(1).and.below(102401)
+ expect(stat.totalVideoFiles).to.equal(4)
+ expect(stat.totalVideos).to.equal(1)
+ }
+}
+
+async function cleanServers () {
+ killallServers(servers)
+}
+
+describe('Test videos redundancy', function () {
+
+ describe('With most-views strategy', function () {
+ const strategy = 'most-views'
+
+ before(function () {
+ this.timeout(120000)
+
+ return runServers(strategy)
+ })
+
+ it('Should have 1 webseed on the first video', function () {
+ return check1WebSeed(strategy)
+ })
+
+ it('Should enable redundancy on server 1', function () {
+ return enableRedundancy()
+ })
+
+ it('Should have 2 webseed on the first video', function () {
+ this.timeout(40000)
+
+ return check2Webseeds(strategy)
+ })
+
+ after(function () {
+ return cleanServers()
+ })
})
- after(async function () {
- killallServers(servers)
+ describe('With trending strategy', function () {
+ const strategy = 'trending'
- // Keep the logs if the test failed
- if (this['ok']) {
- await flushTests()
- }
+ before(function () {
+ this.timeout(120000)
+
+ return runServers(strategy)
+ })
+
+ it('Should have 1 webseed on the first video', function () {
+ return check1WebSeed(strategy)
+ })
+
+ it('Should enable redundancy on server 1', function () {
+ return enableRedundancy()
+ })
+
+ it('Should have 2 webseed on the first video', function () {
+ this.timeout(40000)
+
+ return check2Webseeds(strategy)
+ })
+
+ after(function () {
+ return cleanServers()
+ })
+ })
+
+ describe('With recently added strategy', function () {
+ const strategy = 'recently-added'
+
+ before(function () {
+ this.timeout(120000)
+
+ return runServers(strategy, { minViews: 3 })
+ })
+
+ it('Should have 1 webseed on the first video', function () {
+ return check1WebSeed(strategy)
+ })
+
+ it('Should enable redundancy on server 1', function () {
+ return enableRedundancy()
+ })
+
+ it('Should still have 1 webseed on the first video', async function () {
+ this.timeout(40000)
+
+ await waitJobs(servers)
+ await wait(15000)
+ await waitJobs(servers)
+
+ return check1WebSeed(strategy)
+ })
+
+ it('Should view 2 times the first video', async function () {
+ this.timeout(40000)
+
+ await viewVideo(servers[ 0 ].url, video1Server2UUID)
+ await viewVideo(servers[ 2 ].url, video1Server2UUID)
+
+ await wait(10000)
+ await waitJobs(servers)
+ })
+
+ it('Should have 2 webseed on the first video', function () {
+ this.timeout(40000)
+
+ return check2Webseeds(strategy)
+ })
+
+ after(function () {
+ return cleanServers()
+ })
})
})
const expect = chai.expect
-describe('Test stats', function () {
+describe('Test stats (excluding redundancy)', function () {
let servers: ServerInfo[] = []
before(async function () {
}
}
-function flushAndRunMultipleServers (totalServers) {
+function flushAndRunMultipleServers (totalServers: number, configOverride?: Object) {
let apps = []
let i = 0
flushTests()
.then(() => {
for (let j = 1; j <= totalServers; j++) {
- // For the virtual buffer
- setTimeout(() => {
- runServer(j).then(app => anotherServerDone(j, app))
- }, 1000 * (j - 1))
+ runServer(j, configOverride).then(app => anotherServerDone(j, app))
}
})
})
import { makeGetRequest } from '../'
-function getStats (url: string) {
+function getStats (url: string, useCache = false) {
const path = '/api/v1/server/stats'
+ const query = {
+ t: useCache ? undefined : new Date().getTime()
+ }
+
return makeGetRequest({
url,
path,
+ query,
statusCodeExpected: 200
})
}
-export type VideoRedundancyStrategy = 'most-views'
+export type VideoRedundancyStrategy = 'most-views' | 'trending' | 'recently-added'
-export interface VideosRedundancy {
- strategy: VideoRedundancyStrategy
+export type MostViewsRedundancyStrategy = {
+ strategy: 'most-views'
size: number
}
+
+export type TrendingRedundancyStrategy = {
+ strategy: 'trending'
+ size: number
+}
+
+export type RecentlyAddedStrategy = {
+ strategy: 'recently-added'
+ size: number
+ minViews: number
+}
+
+export type VideosRedundancy = MostViewsRedundancyStrategy | TrendingRedundancyStrategy | RecentlyAddedStrategy
+import { VideoRedundancyStrategy } from '../redundancy'
+
export interface ServerStats {
totalUsers: number
totalLocalVideos: number
totalInstanceFollowers: number
totalInstanceFollowing: number
+
+ videosRedundancy: {
+ strategy: VideoRedundancyStrategy
+ totalSize: number
+ totalUsed: number
+ totalVideoFiles: number
+ totalVideos: number
+ }[]
}
PEERTUBE_WEBSERVER_HOSTNAME=domain.tld
PEERTUBE_WEBSERVER_PORT=443
PEERTUBE_WEBSERVER_HTTPS=true
+PEERTUBE_TRUST_PROXY=127.0.0.1
PEERTUBE_SMTP_USERNAME=
PEERTUBE_SMTP_PASSWORD=
PEERTUBE_SMTP_HOSTNAME=
__name: "PEERTUBE_WEBSERVER_HTTPS"
__format: "json"
+trust_proxy: "PEERTUBE_TRUST_PROXY"
+
database:
hostname: "PEERTUBE_DB_HOSTNAME"
port:
root /var/www/certbot;
}
+ # Bypass PeerTube for performance reasons. Could be removed
location ~ ^/client/(.*\.(js|css|woff2|otf|ttf|woff|eot))$ {
add_header Cache-Control "public, max-age=31536000, immutable";
alias /var/www/peertube/peertube-latest/client/dist/$1;
}
+ # Bypass PeerTube for performance reasons. Could be removed
location ~ ^/static/(thumbnails|avatars)/ {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
send_timeout 600;
}
- # Bypass PeerTube webseed route for better performances
+ # Bypass PeerTube for performance reasons. Could be removed
location /static/webseed {
# Clients usually have 4 simultaneous webseed connections, so the real limit is 3MB/s per client
limit_rate 800k;
dependencies:
"@types/node" "*"
+"@types/memoizee@^0.4.2":
+ version "0.4.2"
+ resolved "https://registry.yarnpkg.com/@types/memoizee/-/memoizee-0.4.2.tgz#a500158999a8144a9b46cf9a9fb49b15f1853573"
+
"@types/mime@*":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.0.tgz#5a7306e367c539b9f6543499de8dd519fac37a8b"
string-template "~0.2.1"
xtend "~4.0.0"
-es5-ext@^0.10.14, es5-ext@^0.10.35, es5-ext@^0.10.9, es5-ext@~0.10.14:
+es5-ext@^0.10.14, es5-ext@^0.10.35, es5-ext@^0.10.45, es5-ext@^0.10.9, es5-ext@~0.10.14, es5-ext@~0.10.2:
version "0.10.46"
resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.46.tgz#efd99f67c5a7ec789baa3daa7f79870388f7f572"
dependencies:
d "1"
es5-ext "~0.10.14"
-es6-weak-map@^2.0.1:
+es6-weak-map@^2.0.1, es6-weak-map@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.2.tgz#5e3ab32251ffd1538a1f8e5ffa1357772f92d96f"
dependencies:
version "1.8.1"
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
-event-emitter@~0.3.5:
+event-emitter@^0.3.5, event-emitter@~0.3.5:
version "0.3.5"
resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39"
dependencies:
dependencies:
isobject "^3.0.1"
-is-promise@^2.1.0:
+is-promise@^2.1, is-promise@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
pseudomap "^1.0.2"
yallist "^2.1.2"
+lru-queue@0.1:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3"
+ dependencies:
+ es5-ext "~0.10.2"
+
lru@^3.0.0, lru@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/lru/-/lru-3.1.0.tgz#ea7fb8546d83733396a13091d76cfeb4c06837d5"
dependencies:
mimic-fn "^1.0.0"
+memoizee@^0.4.14:
+ version "0.4.14"
+ resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57"
+ dependencies:
+ d "1"
+ es5-ext "^0.10.45"
+ es6-weak-map "^2.0.2"
+ event-emitter "^0.3.5"
+ is-promise "^2.1"
+ lru-queue "0.1"
+ next-tick "1"
+ timers-ext "^0.1.5"
+
memory-chunk-store@^1.2.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/memory-chunk-store/-/memory-chunk-store-1.3.0.tgz#ae99e7e3b58b52db43d49d94722930d39459d0c4"
version "4.0.1"
resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
+timers-ext@^0.1.5:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.5.tgz#77147dd4e76b660c2abb8785db96574cbbd12922"
+ dependencies:
+ es5-ext "~0.10.14"
+ next-tick "1"
+
tiny-lr@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/tiny-lr/-/tiny-lr-1.1.1.tgz#9fa547412f238fedb068ee295af8b682c98b2aab"