import * as Bluebird from 'bluebird'
-import { maxBy } from 'lodash'
-import * as magnetUtil from 'magnet-uri'
-import * as parseTorrent from 'parse-torrent'
+import { maxBy, minBy } from 'lodash'
import { join } from 'path'
import {
CountOptions,
} from 'sequelize-typescript'
import { UserRight, VideoPrivacy, VideoState } from '../../../shared'
import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
-import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
+import { Video, VideoDetails } from '../../../shared/models/videos'
import { VideoFilter } from '../../../shared/models/videos/video-query.type'
import { peertubeTruncate } from '../../helpers/core-utils'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
-import { isArray, isBooleanValid } from '../../helpers/custom-validators/misc'
+import { isBooleanValid } from '../../helpers/custom-validators/misc'
import {
isVideoCategoryValid,
isVideoDescriptionValid,
ACTIVITY_PUB,
API_VERSION,
CONSTRAINTS_FIELDS,
- HLS_REDUNDANCY_DIRECTORY,
- HLS_STREAMING_PLAYLIST_DIRECTORY,
LAZY_STATIC_PATHS,
REMOTE_SCHEME,
STATIC_DOWNLOAD_PATHS,
import { ScheduleVideoUpdateModel } from './schedule-video-update'
import { VideoCaptionModel } from './video-caption'
import { VideoBlacklistModel } from './video-blacklist'
-import { remove, writeFile } from 'fs-extra'
+import { remove } from 'fs-extra'
import { VideoViewModel } from './video-views'
import { VideoRedundancyModel } from '../redundancy/video-redundancy'
import {
import { CONFIG } from '../../initializers/config'
import { ThumbnailModel } from './thumbnail'
import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
-import { createTorrentPromise } from '../../helpers/webtorrent'
import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
import {
MChannel,
MChannelAccountDefault,
MChannelId,
+ MStreamingPlaylist,
+ MStreamingPlaylistFilesVideo,
MUserAccountId,
MUserId,
MVideoAccountLight,
MVideoAccountLightBlacklistAllFiles,
MVideoAP,
MVideoDetails,
+ MVideoFileVideo,
MVideoFormattable,
MVideoFormattableDetails,
MVideoForUser,
MVideoWithFile,
MVideoWithRights
} from '../../typings/models'
-import { MVideoFile, MVideoFileRedundanciesOpt } from '../../typings/models/video/video-file'
+import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../typings/models/video/video-file'
import { MThumbnail } from '../../typings/models/video/thumbnail'
+import { VideoFile } from '@shared/models/videos/video-file.model'
+import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
+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: (ModelIndexesOptions & { where?: WhereOptions })[] = [
FOR_API = 'FOR_API',
WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
WITH_TAGS = 'WITH_TAGS',
- WITH_FILES = 'WITH_FILES',
+ WITH_WEBTORRENT_FILES = 'WITH_WEBTORRENT_FILES',
WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
WITH_BLACKLISTED = 'WITH_BLACKLISTED',
WITH_BLOCKLIST = 'WITH_BLOCKLIST',
// Only list public/published videos
if (!options.filter || options.filter !== 'all-local') {
- const privacyWhere = {
- // Always list public videos
- privacy: VideoPrivacy.PUBLIC,
+
+ const publishWhere = {
// Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding
[ Op.or ]: [
{
}
]
}
+ whereAnd.push(publishWhere)
- whereAnd.push(privacyWhere)
+ // List internal videos if the user is logged in
+ if (options.user) {
+ const privacyWhere = {
+ [Op.or]: [
+ {
+ privacy: VideoPrivacy.INTERNAL
+ },
+ {
+ privacy: VideoPrivacy.PUBLIC
+ }
+ ]
+ }
+
+ whereAnd.push(privacyWhere)
+ } else { // Or only public videos
+ const privacyWhere = { privacy: VideoPrivacy.PUBLIC }
+ whereAnd.push(privacyWhere)
+ }
}
if (options.videoPlaylistId) {
}
]
},
- [ ScopeNames.WITH_FILES ]: (withRedundancies = false) => {
+ [ ScopeNames.WITH_WEBTORRENT_FILES ]: (withRedundancies = false) => {
let subInclude: any[] = []
if (withRedundancies === true) {
}
},
[ ScopeNames.WITH_STREAMING_PLAYLISTS ]: (withRedundancies = false) => {
- let subInclude: any[] = []
+ const subInclude: IncludeOptions[] = [
+ {
+ model: VideoFileModel.unscoped(),
+ required: false
+ }
+ ]
if (withRedundancies === true) {
- subInclude = [
- {
- attributes: [ 'fileUrl' ],
- model: VideoRedundancyModel.unscoped(),
- required: false
- }
- ]
+ subInclude.push({
+ attributes: [ 'fileUrl' ],
+ model: VideoRedundancyModel.unscoped(),
+ required: false
+ })
}
return {
@HasMany(() => VideoFileModel, {
foreignKey: {
name: 'videoId',
- allowNull: false
+ allowNull: true
},
hooks: true,
onDelete: 'cascade'
}
return VideoModel.scope([
- ScopeNames.WITH_FILES,
+ ScopeNames.WITH_WEBTORRENT_FILES,
ScopeNames.WITH_STREAMING_PLAYLISTS,
ScopeNames.WITH_THUMBNAILS
]).findAll(query)
const escapedSearch = VideoModel.sequelize.escape(options.search)
const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
if (options.search) {
- whereAnd.push(
- {
- id: {
- [ Op.in ]: Sequelize.literal(
- '(' +
- 'SELECT "video"."id" FROM "video" ' +
- 'WHERE ' +
- 'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
- 'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
- 'UNION ALL ' +
- 'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' +
- 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
- 'WHERE "tag"."name" = ' + escapedSearch +
- ')'
- )
- }
+ const trigramSearch = {
+ id: {
+ [ Op.in ]: Sequelize.literal(
+ '(' +
+ 'SELECT "video"."id" FROM "video" ' +
+ 'WHERE ' +
+ 'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
+ 'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
+ 'UNION ALL ' +
+ 'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' +
+ 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
+ 'WHERE "tag"."name" = ' + escapedSearch +
+ ')'
+ )
}
- )
+ }
+
+ if (validator.isUUID(options.search)) {
+ whereAnd.push({
+ [Op.or]: [
+ trigramSearch,
+ {
+ uuid: options.search
+ }
+ ]
+ })
+ } else {
+ whereAnd.push(trigramSearch)
+ }
attributesInclude.push(createSimilarityAttribute('VideoModel.name', options.search))
}
}
return VideoModel.scope([
- ScopeNames.WITH_FILES,
+ ScopeNames.WITH_WEBTORRENT_FILES,
ScopeNames.WITH_STREAMING_PLAYLISTS,
ScopeNames.WITH_THUMBNAILS
]).findOne(query)
return VideoModel.scope([
ScopeNames.WITH_ACCOUNT_DETAILS,
- ScopeNames.WITH_FILES,
+ ScopeNames.WITH_WEBTORRENT_FILES,
ScopeNames.WITH_STREAMING_PLAYLISTS,
ScopeNames.WITH_THUMBNAILS,
ScopeNames.WITH_BLACKLISTED
ScopeNames.WITH_BLACKLISTED,
ScopeNames.WITH_ACCOUNT_DETAILS,
ScopeNames.WITH_SCHEDULED_UPDATE,
- ScopeNames.WITH_FILES,
+ ScopeNames.WITH_WEBTORRENT_FILES,
ScopeNames.WITH_STREAMING_PLAYLISTS,
ScopeNames.WITH_THUMBNAILS
]
ScopeNames.WITH_ACCOUNT_DETAILS,
ScopeNames.WITH_SCHEDULED_UPDATE,
ScopeNames.WITH_THUMBNAILS,
- { method: [ ScopeNames.WITH_FILES, true ] },
+ { method: [ ScopeNames.WITH_WEBTORRENT_FILES, true ] },
{ method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
]
}
}
+ private static isPrivacyForFederation (privacy: VideoPrivacy) {
+ return privacy === VideoPrivacy.PUBLIC || privacy === VideoPrivacy.UNLISTED
+ }
+
static getCategoryLabel (id: number) {
return VIDEO_CATEGORIES[ id ] || 'Misc'
}
this.VideoChannel.Account.isBlocked()
}
- getOriginalFile <T extends MVideoWithFile> (this: T) {
- if (Array.isArray(this.VideoFiles) === false) return undefined
+ getQualityFileBy <T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
+ if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) {
+ const file = fun(this.VideoFiles, file => file.resolution)
+
+ return Object.assign(file, { Video: this })
+ }
+
+ // No webtorrent files, try with streaming playlist files
+ if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) {
+ const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
+
+ const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution)
+ return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
+ }
+
+ return undefined
+ }
- // The original file is the file that have the higher resolution
- return maxBy(this.VideoFiles, file => file.resolution)
+ getMaxQualityFile <T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
+ return this.getQualityFileBy(maxBy)
}
- getFile <T extends MVideoWithFile> (this: T, resolution: number) {
+ getMinQualityFile <T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
+ return this.getQualityFileBy(minBy)
+ }
+
+ getWebTorrentFile <T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
if (Array.isArray(this.VideoFiles) === false) return undefined
- return this.VideoFiles.find(f => f.resolution === resolution)
+ const file = this.VideoFiles.find(f => f.resolution === resolution)
+ if (!file) return undefined
+
+ return Object.assign(file, { Video: this })
}
async addAndSaveThumbnail (thumbnail: MThumbnail, transaction: Transaction) {
this.Thumbnails.push(savedThumbnail)
}
- getVideoFilename (videoFile: MVideoFile) {
- return this.uuid + '-' + videoFile.resolution + videoFile.extname
- }
-
generateThumbnailName () {
return this.uuid + '.jpg'
}
return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
}
- getTorrentFileName (videoFile: MVideoFile) {
- const extension = '.torrent'
- return this.uuid + '-' + videoFile.resolution + extension
- }
-
isOwned () {
return this.remote === false
}
- getTorrentFilePath (videoFile: MVideoFile) {
- return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
- }
-
- getVideoFilePath (videoFile: MVideoFile) {
- return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
- }
-
- async createTorrentAndSetInfoHash (videoFile: MVideoFile) {
- const options = {
- // Keep the extname, it's used by the client to stream the file inside a web browser
- name: `${this.name} ${videoFile.resolution}p${videoFile.extname}`,
- createdBy: 'PeerTube',
- announceList: [
- [ WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket' ],
- [ WEBSERVER.URL + '/tracker/announce' ]
- ],
- urlList: [ WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
- }
-
- const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
-
- const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
- logger.info('Creating torrent %s.', filePath)
-
- await writeFile(filePath, torrent)
-
- const parsedTorrent = parseTorrent(torrent)
- videoFile.infoHash = parsedTorrent.infoHash
- }
-
getWatchStaticPath () {
return '/videos/watch/' + this.uuid
}
}
getFormattedVideoFilesJSON (): VideoFile[] {
- return videoFilesModelToFormattedJSON(this, this.VideoFiles)
+ const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
+ return videoFilesModelToFormattedJSON(this, baseUrlHttp, baseUrlWs, this.VideoFiles)
}
toActivityPubObject (this: MVideoAP): VideoTorrentObject {
return peertubeTruncate(this.description, { length: maxLength })
}
- getOriginalFileResolution () {
- const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
+ getMaxQualityResolution () {
+ const file = this.getMaxQualityFile()
+ const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
+ const originalFilePath = getVideoFilePath(videoOrPlaylist, file)
return getVideoFileResolution(originalFilePath)
}
return `/api/${API_VERSION}/videos/${this.uuid}/description`
}
- getHLSPlaylist () {
+ getHLSPlaylist (): MStreamingPlaylistFilesVideo {
if (!this.VideoStreamingPlaylists) return undefined
- return this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
+ const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
+ playlist.Video = this
+
+ return playlist
}
- removeFile (videoFile: MVideoFile, isRedundancy = false) {
- const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR
+ setHLSPlaylist (playlist: MStreamingPlaylist) {
+ const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
+
+ if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
+ this.VideoStreamingPlaylists = toAdd
+ return
+ }
- const filePath = join(baseDir, this.getVideoFilename(videoFile))
+ this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
+ .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
+ .concat(toAdd)
+ }
+
+ removeFile (videoFile: MVideoFile, isRedundancy = false) {
+ const filePath = getVideoFilePath(this, videoFile, isRedundancy)
return remove(filePath)
.catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
}
removeTorrent (videoFile: MVideoFile) {
- const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
+ const torrentPath = getTorrentFilePath(this, videoFile)
return remove(torrentPath)
.catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
}
removeStreamingPlaylist (isRedundancy = false) {
- const baseDir = isRedundancy ? HLS_REDUNDANCY_DIRECTORY : HLS_STREAMING_PLAYLIST_DIRECTORY
+ const directoryPath = getHLSDirectory(this, isRedundancy)
- const filePath = join(baseDir, this.uuid)
- return remove(filePath)
- .catch(err => logger.warn('Cannot delete playlist directory %s.', filePath, { err }))
+ return remove(directoryPath)
+ .catch(err => logger.warn('Cannot delete playlist directory %s.', directoryPath, { err }))
}
isOutdated () {
return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
}
+ hasPrivacyForFederation () {
+ return VideoModel.isPrivacyForFederation(this.privacy)
+ }
+
+ isNewVideo (newPrivacy: VideoPrivacy) {
+ return this.hasPrivacyForFederation() === false && VideoModel.isPrivacyForFederation(newPrivacy) === true
+ }
+
setAsRefreshed () {
this.changed('updatedAt', true)
return this.save()
}
- getBaseUrls () {
- let baseUrlHttp
- let baseUrlWs
+ requiresAuth () {
+ return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist
+ }
- if (this.isOwned()) {
- baseUrlHttp = WEBSERVER.URL
- baseUrlWs = WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT
- } else {
- baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
- baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
+ setPrivacy (newPrivacy: VideoPrivacy) {
+ if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
+ this.publishedAt = new Date()
}
- return { baseUrlHttp, baseUrlWs }
+ this.privacy = newPrivacy
}
- generateMagnetUri (videoFile: MVideoFileRedundanciesOpt, baseUrlHttp: string, baseUrlWs: string) {
- const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
- const announce = this.getTrackerUrls(baseUrlHttp, baseUrlWs)
- let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
+ isConfidential () {
+ return this.privacy === VideoPrivacy.PRIVATE ||
+ this.privacy === VideoPrivacy.UNLISTED ||
+ this.privacy === VideoPrivacy.INTERNAL
+ }
- const redundancies = videoFile.RedundancyVideos
- if (isArray(redundancies)) urlList = urlList.concat(redundancies.map(r => r.fileUrl))
+ async publishIfNeededAndSave (t: Transaction) {
+ if (this.state !== VideoState.PUBLISHED) {
+ this.state = VideoState.PUBLISHED
+ this.publishedAt = new Date()
+ await this.save({ transaction: t })
- const magnetHash = {
- xs,
- announce,
- urlList,
- infoHash: videoFile.infoHash,
- name: this.name
+ return true
}
- return magnetUtil.encode(magnetHash)
+ return false
+ }
+
+ getBaseUrls () {
+ if (this.isOwned()) {
+ return {
+ baseUrlHttp: WEBSERVER.URL,
+ baseUrlWs: WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT
+ }
+ }
+
+ return {
+ baseUrlHttp: REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host,
+ baseUrlWs: REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
+ }
}
getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
}
getTorrentUrl (videoFile: MVideoFile, baseUrlHttp: string) {
- return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
+ return baseUrlHttp + STATIC_PATHS.TORRENTS + getTorrentFileName(this, videoFile)
}
getTorrentDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
- return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
+ return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + getTorrentFileName(this, videoFile)
}
getVideoFileUrl (videoFile: MVideoFile, baseUrlHttp: string) {
- return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
+ return baseUrlHttp + STATIC_PATHS.WEBSEED + getVideoFilename(this, videoFile)
}
getVideoRedundancyUrl (videoFile: MVideoFile, baseUrlHttp: string) {
- return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getVideoFilename(videoFile)
+ return baseUrlHttp + STATIC_PATHS.REDUNDANCY + getVideoFilename(this, videoFile)
}
getVideoFileDownloadUrl (videoFile: MVideoFile, baseUrlHttp: string) {
- return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
+ return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + getVideoFilename(this, videoFile)
}
getBandwidthBits (videoFile: MVideoFile) {