aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models/video
diff options
context:
space:
mode:
Diffstat (limited to 'server/models/video')
-rw-r--r--server/models/video/schedule-video-update.ts12
-rw-r--r--server/models/video/tag.ts8
-rw-r--r--server/models/video/thumbnail.ts116
-rw-r--r--server/models/video/video-abuse.ts4
-rw-r--r--server/models/video/video-blacklist.ts49
-rw-r--r--server/models/video/video-caption.ts26
-rw-r--r--server/models/video/video-change-ownership.ts44
-rw-r--r--server/models/video/video-channel.ts122
-rw-r--r--server/models/video/video-comment.ts85
-rw-r--r--server/models/video/video-file.ts57
-rw-r--r--server/models/video/video-format-utils.ts115
-rw-r--r--server/models/video/video-import.ts16
-rw-r--r--server/models/video/video-playlist-element.ts230
-rw-r--r--server/models/video/video-playlist.ts531
-rw-r--r--server/models/video/video-share.ts27
-rw-r--r--server/models/video/video-streaming-playlist.ts172
-rw-r--r--server/models/video/video-views.ts15
-rw-r--r--server/models/video/video.ts764
18 files changed, 1914 insertions, 479 deletions
diff --git a/server/models/video/schedule-video-update.ts b/server/models/video/schedule-video-update.ts
index 1e56562e1..603d55692 100644
--- a/server/models/video/schedule-video-update.ts
+++ b/server/models/video/schedule-video-update.ts
@@ -1,7 +1,7 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Sequelize, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { ScopeNames as VideoScopeNames, VideoModel } from './video' 2import { ScopeNames as VideoScopeNames, VideoModel } from './video'
3import { VideoPrivacy } from '../../../shared/models/videos' 3import { VideoPrivacy } from '../../../shared/models/videos'
4import { Transaction } from 'sequelize' 4import { Op, Transaction } from 'sequelize'
5 5
6@Table({ 6@Table({
7 tableName: 'scheduleVideoUpdate', 7 tableName: 'scheduleVideoUpdate',
@@ -51,7 +51,7 @@ export class ScheduleVideoUpdateModel extends Model<ScheduleVideoUpdateModel> {
51 attributes: [ 'id' ], 51 attributes: [ 'id' ],
52 where: { 52 where: {
53 updateAt: { 53 updateAt: {
54 [Sequelize.Op.lte]: new Date() 54 [Op.lte]: new Date()
55 } 55 }
56 } 56 }
57 } 57 }
@@ -64,7 +64,7 @@ export class ScheduleVideoUpdateModel extends Model<ScheduleVideoUpdateModel> {
64 const query = { 64 const query = {
65 where: { 65 where: {
66 updateAt: { 66 updateAt: {
67 [Sequelize.Op.lte]: new Date() 67 [Op.lte]: new Date()
68 } 68 }
69 }, 69 },
70 include: [ 70 include: [
@@ -72,7 +72,9 @@ export class ScheduleVideoUpdateModel extends Model<ScheduleVideoUpdateModel> {
72 model: VideoModel.scope( 72 model: VideoModel.scope(
73 [ 73 [
74 VideoScopeNames.WITH_FILES, 74 VideoScopeNames.WITH_FILES,
75 VideoScopeNames.WITH_ACCOUNT_DETAILS 75 VideoScopeNames.WITH_ACCOUNT_DETAILS,
76 VideoScopeNames.WITH_BLACKLISTED,
77 VideoScopeNames.WITH_THUMBNAILS
76 ] 78 ]
77 ) 79 )
78 } 80 }
diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts
index b39621eaf..0fc3cfd4c 100644
--- a/server/models/video/tag.ts
+++ b/server/models/video/tag.ts
@@ -1,5 +1,5 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import * as Sequelize from 'sequelize' 2import { QueryTypes, Transaction } from 'sequelize'
3import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 3import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
4import { isVideoTagValid } from '../../helpers/custom-validators/videos' 4import { isVideoTagValid } from '../../helpers/custom-validators/videos'
5import { throwIfNotValid } from '../utils' 5import { throwIfNotValid } from '../utils'
@@ -37,7 +37,7 @@ export class TagModel extends Model<TagModel> {
37 }) 37 })
38 Videos: VideoModel[] 38 Videos: VideoModel[]
39 39
40 static findOrCreateTags (tags: string[], transaction: Sequelize.Transaction) { 40 static findOrCreateTags (tags: string[], transaction: Transaction) {
41 if (tags === null) return [] 41 if (tags === null) return []
42 42
43 const tasks: Bluebird<TagModel>[] = [] 43 const tasks: Bluebird<TagModel>[] = []
@@ -72,10 +72,10 @@ export class TagModel extends Model<TagModel> {
72 72
73 const options = { 73 const options = {
74 bind: { threshold, count, videoPrivacy: VideoPrivacy.PUBLIC, videoState: VideoState.PUBLISHED }, 74 bind: { threshold, count, videoPrivacy: VideoPrivacy.PUBLIC, videoState: VideoState.PUBLISHED },
75 type: Sequelize.QueryTypes.SELECT 75 type: QueryTypes.SELECT as QueryTypes.SELECT
76 } 76 }
77 77
78 return TagModel.sequelize.query(query, options) 78 return TagModel.sequelize.query<{ name: string }>(query, options)
79 .then(data => data.map(d => d.name)) 79 .then(data => data.map(d => d.name))
80 } 80 }
81} 81}
diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts
new file mode 100644
index 000000000..206e9a3d6
--- /dev/null
+++ b/server/models/video/thumbnail.ts
@@ -0,0 +1,116 @@
1import { join } from 'path'
2import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
4import { logger } from '../../helpers/logger'
5import { remove } from 'fs-extra'
6import { CONFIG } from '../../initializers/config'
7import { VideoModel } from './video'
8import { VideoPlaylistModel } from './video-playlist'
9import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
10
11@Table({
12 tableName: 'thumbnail',
13 indexes: [
14 {
15 fields: [ 'videoId' ]
16 },
17 {
18 fields: [ 'videoPlaylistId' ],
19 unique: true
20 }
21 ]
22})
23export class ThumbnailModel extends Model<ThumbnailModel> {
24
25 @AllowNull(false)
26 @Column
27 filename: string
28
29 @AllowNull(true)
30 @Default(null)
31 @Column
32 height: number
33
34 @AllowNull(true)
35 @Default(null)
36 @Column
37 width: number
38
39 @AllowNull(false)
40 @Column
41 type: ThumbnailType
42
43 @AllowNull(true)
44 @Column
45 fileUrl: string
46
47 @ForeignKey(() => VideoModel)
48 @Column
49 videoId: number
50
51 @BelongsTo(() => VideoModel, {
52 foreignKey: {
53 allowNull: true
54 },
55 onDelete: 'CASCADE'
56 })
57 Video: VideoModel
58
59 @ForeignKey(() => VideoPlaylistModel)
60 @Column
61 videoPlaylistId: number
62
63 @BelongsTo(() => VideoPlaylistModel, {
64 foreignKey: {
65 allowNull: true
66 },
67 onDelete: 'CASCADE'
68 })
69 VideoPlaylist: VideoPlaylistModel
70
71 @CreatedAt
72 createdAt: Date
73
74 @UpdatedAt
75 updatedAt: Date
76
77 private static types: { [ id in ThumbnailType ]: { label: string, directory: string, staticPath: string } } = {
78 [ThumbnailType.MINIATURE]: {
79 label: 'miniature',
80 directory: CONFIG.STORAGE.THUMBNAILS_DIR,
81 staticPath: STATIC_PATHS.THUMBNAILS
82 },
83 [ThumbnailType.PREVIEW]: {
84 label: 'preview',
85 directory: CONFIG.STORAGE.PREVIEWS_DIR,
86 staticPath: STATIC_PATHS.PREVIEWS
87 }
88 }
89
90 @AfterDestroy
91 static removeFilesAndSendDelete (instance: ThumbnailModel) {
92 logger.info('Removing %s file %s.', ThumbnailModel.types[instance.type].label, instance.filename)
93
94 // Don't block the transaction
95 instance.removeThumbnail()
96 .catch(err => logger.error('Cannot remove thumbnail file %s.', instance.filename, err))
97 }
98
99 static generateDefaultPreviewName (videoUUID: string) {
100 return videoUUID + '.jpg'
101 }
102
103 getFileUrl () {
104 if (this.fileUrl) return this.fileUrl
105
106 const staticPath = ThumbnailModel.types[this.type].staticPath
107 return WEBSERVER.URL + staticPath + this.filename
108 }
109
110 removeThumbnail () {
111 const directory = ThumbnailModel.types[this.type].directory
112 const thumbnailPath = join(directory, this.filename)
113
114 return remove(thumbnailPath)
115 }
116}
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts
index cc47644f2..1ac7919b3 100644
--- a/server/models/video/video-abuse.ts
+++ b/server/models/video/video-abuse.ts
@@ -10,7 +10,7 @@ import { AccountModel } from '../account/account'
10import { getSort, throwIfNotValid } from '../utils' 10import { getSort, throwIfNotValid } from '../utils'
11import { VideoModel } from './video' 11import { VideoModel } from './video'
12import { VideoAbuseState } from '../../../shared' 12import { VideoAbuseState } from '../../../shared'
13import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers' 13import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
14 14
15@Table({ 15@Table({
16 tableName: 'videoAbuse', 16 tableName: 'videoAbuse',
@@ -39,7 +39,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
39 39
40 @AllowNull(true) 40 @AllowNull(true)
41 @Default(null) 41 @Default(null)
42 @Is('VideoAbuseModerationComment', value => throwIfNotValid(value, isVideoAbuseModerationCommentValid, 'moderationComment')) 42 @Is('VideoAbuseModerationComment', value => throwIfNotValid(value, isVideoAbuseModerationCommentValid, 'moderationComment', true))
43 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max)) 43 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max))
44 moderationComment: string 44 moderationComment: string
45 45
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index 3b567e488..d9fe9dfc9 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -1,9 +1,11 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { getSortOnModel, SortType, throwIfNotValid } from '../utils' 2import { getSortOnModel, SortType, throwIfNotValid } from '../utils'
3import { VideoModel } from './video' 3import { VideoModel } from './video'
4import { isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist' 4import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
5import { VideoBlacklist } from '../../../shared/models/videos' 5import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
6import { CONSTRAINTS_FIELDS } from '../../initializers' 6import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
7import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
8import { FindOptions } from 'sequelize'
7 9
8@Table({ 10@Table({
9 tableName: 'videoBlacklist', 11 tableName: 'videoBlacklist',
@@ -17,7 +19,7 @@ import { CONSTRAINTS_FIELDS } from '../../initializers'
17export class VideoBlacklistModel extends Model<VideoBlacklistModel> { 19export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
18 20
19 @AllowNull(true) 21 @AllowNull(true)
20 @Is('VideoBlacklistReason', value => throwIfNotValid(value, isVideoBlacklistReasonValid, 'reason')) 22 @Is('VideoBlacklistReason', value => throwIfNotValid(value, isVideoBlacklistReasonValid, 'reason', true))
21 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max)) 23 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max))
22 reason: string 24 reason: string
23 25
@@ -25,6 +27,12 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
25 @Column 27 @Column
26 unfederated: boolean 28 unfederated: boolean
27 29
30 @AllowNull(false)
31 @Default(null)
32 @Is('VideoBlacklistType', value => throwIfNotValid(value, isVideoBlacklistTypeValid, 'type'))
33 @Column
34 type: VideoBlacklistType
35
28 @CreatedAt 36 @CreatedAt
29 createdAt: Date 37 createdAt: Date
30 38
@@ -43,19 +51,29 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
43 }) 51 })
44 Video: VideoModel 52 Video: VideoModel
45 53
46 static listForApi (start: number, count: number, sort: SortType) { 54 static listForApi (start: number, count: number, sort: SortType, type?: VideoBlacklistType) {
47 const query = { 55 const query: FindOptions = {
48 offset: start, 56 offset: start,
49 limit: count, 57 limit: count,
50 order: getSortOnModel(sort.sortModel, sort.sortValue), 58 order: getSortOnModel(sort.sortModel, sort.sortValue),
51 include: [ 59 include: [
52 { 60 {
53 model: VideoModel, 61 model: VideoModel,
54 required: true 62 required: true,
63 include: [
64 {
65 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, true ] }),
66 required: true
67 }
68 ]
55 } 69 }
56 ] 70 ]
57 } 71 }
58 72
73 if (type) {
74 query.where = { type }
75 }
76
59 return VideoBlacklistModel.findAndCountAll(query) 77 return VideoBlacklistModel.findAndCountAll(query)
60 .then(({ rows, count }) => { 78 .then(({ rows, count }) => {
61 return { 79 return {
@@ -76,26 +94,15 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
76 } 94 }
77 95
78 toFormattedJSON (): VideoBlacklist { 96 toFormattedJSON (): VideoBlacklist {
79 const video = this.Video
80
81 return { 97 return {
82 id: this.id, 98 id: this.id,
83 createdAt: this.createdAt, 99 createdAt: this.createdAt,
84 updatedAt: this.updatedAt, 100 updatedAt: this.updatedAt,
85 reason: this.reason, 101 reason: this.reason,
86 unfederated: this.unfederated, 102 unfederated: this.unfederated,
103 type: this.type,
87 104
88 video: { 105 video: this.Video.toFormattedJSON()
89 id: video.id,
90 name: video.name,
91 uuid: video.uuid,
92 description: video.description,
93 duration: video.duration,
94 views: video.views,
95 likes: video.likes,
96 dislikes: video.dislikes,
97 nsfw: video.nsfw
98 }
99 } 106 }
100 } 107 }
101} 108}
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
index b4f17b481..76243bf48 100644
--- a/server/models/video/video-caption.ts
+++ b/server/models/video/video-caption.ts
@@ -1,4 +1,4 @@
1import * as Sequelize from 'sequelize' 1import { OrderItem, Transaction } from 'sequelize'
2import { 2import {
3 AllowNull, 3 AllowNull,
4 BeforeDestroy, 4 BeforeDestroy,
@@ -12,30 +12,31 @@ import {
12 Table, 12 Table,
13 UpdatedAt 13 UpdatedAt
14} from 'sequelize-typescript' 14} from 'sequelize-typescript'
15import { throwIfNotValid } from '../utils' 15import { buildWhereIdOrUUID, throwIfNotValid } from '../utils'
16import { VideoModel } from './video' 16import { VideoModel } from './video'
17import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' 17import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
18import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' 18import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model'
19import { CONFIG, STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers' 19import { STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers/constants'
20import { join } from 'path' 20import { join } from 'path'
21import { logger } from '../../helpers/logger' 21import { logger } from '../../helpers/logger'
22import { remove } from 'fs-extra' 22import { remove } from 'fs-extra'
23import { CONFIG } from '../../initializers/config'
23 24
24export enum ScopeNames { 25export enum ScopeNames {
25 WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE' 26 WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE'
26} 27}
27 28
28@Scopes({ 29@Scopes(() => ({
29 [ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: { 30 [ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: {
30 include: [ 31 include: [
31 { 32 {
32 attributes: [ 'uuid', 'remote' ], 33 attributes: [ 'uuid', 'remote' ],
33 model: () => VideoModel.unscoped(), 34 model: VideoModel.unscoped(),
34 required: true 35 required: true
35 } 36 }
36 ] 37 ]
37 } 38 }
38}) 39}))
39 40
40@Table({ 41@Table({
41 tableName: 'videoCaption', 42 tableName: 'videoCaption',
@@ -96,12 +97,9 @@ export class VideoCaptionModel extends Model<VideoCaptionModel> {
96 const videoInclude = { 97 const videoInclude = {
97 model: VideoModel.unscoped(), 98 model: VideoModel.unscoped(),
98 attributes: [ 'id', 'remote', 'uuid' ], 99 attributes: [ 'id', 'remote', 'uuid' ],
99 where: { } 100 where: buildWhereIdOrUUID(videoId)
100 } 101 }
101 102
102 if (typeof videoId === 'string') videoInclude.where['uuid'] = videoId
103 else videoInclude.where['id'] = videoId
104
105 const query = { 103 const query = {
106 where: { 104 where: {
107 language 105 language
@@ -114,19 +112,19 @@ export class VideoCaptionModel extends Model<VideoCaptionModel> {
114 return VideoCaptionModel.findOne(query) 112 return VideoCaptionModel.findOne(query)
115 } 113 }
116 114
117 static insertOrReplaceLanguage (videoId: number, language: string, transaction: Sequelize.Transaction) { 115 static insertOrReplaceLanguage (videoId: number, language: string, transaction: Transaction) {
118 const values = { 116 const values = {
119 videoId, 117 videoId,
120 language 118 language
121 } 119 }
122 120
123 return VideoCaptionModel.upsert<VideoCaptionModel>(values, { transaction, returning: true }) 121 return (VideoCaptionModel.upsert<VideoCaptionModel>(values, { transaction, returning: true }) as any) // FIXME: typings
124 .then(([ caption ]) => caption) 122 .then(([ caption ]) => caption)
125 } 123 }
126 124
127 static listVideoCaptions (videoId: number) { 125 static listVideoCaptions (videoId: number) {
128 const query = { 126 const query = {
129 order: [ [ 'language', 'ASC' ] ], 127 order: [ [ 'language', 'ASC' ] ] as OrderItem[],
130 where: { 128 where: {
131 videoId 129 videoId
132 } 130 }
@@ -139,7 +137,7 @@ export class VideoCaptionModel extends Model<VideoCaptionModel> {
139 return VIDEO_LANGUAGES[language] || 'Unknown' 137 return VIDEO_LANGUAGES[language] || 'Unknown'
140 } 138 }
141 139
142 static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Sequelize.Transaction) { 140 static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Transaction) {
143 const query = { 141 const query = {
144 where: { 142 where: {
145 videoId 143 videoId
diff --git a/server/models/video/video-change-ownership.ts b/server/models/video/video-change-ownership.ts
index 48c07728f..b545a2f8c 100644
--- a/server/models/video/video-change-ownership.ts
+++ b/server/models/video/video-change-ownership.ts
@@ -1,51 +1,52 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { AccountModel } from '../account/account' 2import { AccountModel } from '../account/account'
3import { VideoModel } from './video' 3import { ScopeNames as VideoScopeNames, VideoModel } from './video'
4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos' 4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos'
5import { getSort } from '../utils' 5import { getSort } from '../utils'
6import { VideoFileModel } from './video-file'
7 6
8enum ScopeNames { 7enum ScopeNames {
9 FULL = 'FULL' 8 WITH_ACCOUNTS = 'WITH_ACCOUNTS',
9 WITH_VIDEO = 'WITH_VIDEO'
10} 10}
11 11
12@Table({ 12@Table({
13 tableName: 'videoChangeOwnership', 13 tableName: 'videoChangeOwnership',
14 indexes: [ 14 indexes: [
15 { 15 {
16 fields: ['videoId'] 16 fields: [ 'videoId' ]
17 }, 17 },
18 { 18 {
19 fields: ['initiatorAccountId'] 19 fields: [ 'initiatorAccountId' ]
20 }, 20 },
21 { 21 {
22 fields: ['nextOwnerAccountId'] 22 fields: [ 'nextOwnerAccountId' ]
23 } 23 }
24 ] 24 ]
25}) 25})
26@Scopes({ 26@Scopes(() => ({
27 [ScopeNames.FULL]: { 27 [ScopeNames.WITH_ACCOUNTS]: {
28 include: [ 28 include: [
29 { 29 {
30 model: () => AccountModel, 30 model: AccountModel,
31 as: 'Initiator', 31 as: 'Initiator',
32 required: true 32 required: true
33 }, 33 },
34 { 34 {
35 model: () => AccountModel, 35 model: AccountModel,
36 as: 'NextOwner', 36 as: 'NextOwner',
37 required: true 37 required: true
38 }, 38 }
39 ]
40 },
41 [ScopeNames.WITH_VIDEO]: {
42 include: [
39 { 43 {
40 model: () => VideoModel, 44 model: VideoModel.scope([ VideoScopeNames.WITH_THUMBNAILS, VideoScopeNames.WITH_FILES ]),
41 required: true, 45 required: true
42 include: [
43 { model: () => VideoFileModel }
44 ]
45 } 46 }
46 ] 47 ]
47 } 48 }
48}) 49}))
49export class VideoChangeOwnershipModel extends Model<VideoChangeOwnershipModel> { 50export class VideoChangeOwnershipModel extends Model<VideoChangeOwnershipModel> {
50 @CreatedAt 51 @CreatedAt
51 createdAt: Date 52 createdAt: Date
@@ -105,12 +106,15 @@ export class VideoChangeOwnershipModel extends Model<VideoChangeOwnershipModel>
105 } 106 }
106 } 107 }
107 108
108 return VideoChangeOwnershipModel.scope(ScopeNames.FULL).findAndCountAll(query) 109 return Promise.all([
109 .then(({ rows, count }) => ({ total: count, data: rows })) 110 VideoChangeOwnershipModel.scope(ScopeNames.WITH_ACCOUNTS).count(query),
111 VideoChangeOwnershipModel.scope([ ScopeNames.WITH_ACCOUNTS, ScopeNames.WITH_VIDEO ]).findAll(query)
112 ]).then(([ count, rows ]) => ({ total: count, data: rows }))
110 } 113 }
111 114
112 static load (id: number) { 115 static load (id: number) {
113 return VideoChangeOwnershipModel.scope(ScopeNames.FULL).findById(id) 116 return VideoChangeOwnershipModel.scope([ ScopeNames.WITH_ACCOUNTS, ScopeNames.WITH_VIDEO ])
117 .findByPk(id)
114 } 118 }
115 119
116 toFormattedJSON (): VideoChangeOwnership { 120 toFormattedJSON (): VideoChangeOwnership {
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 5598d80f6..fb70e6625 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -17,23 +17,25 @@ import {
17 UpdatedAt 17 UpdatedAt
18} from 'sequelize-typescript' 18} from 'sequelize-typescript'
19import { ActivityPubActor } from '../../../shared/models/activitypub' 19import { ActivityPubActor } from '../../../shared/models/activitypub'
20import { VideoChannel } from '../../../shared/models/videos' 20import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
21import { 21import {
22 isVideoChannelDescriptionValid, 22 isVideoChannelDescriptionValid,
23 isVideoChannelNameValid, 23 isVideoChannelNameValid,
24 isVideoChannelSupportValid 24 isVideoChannelSupportValid
25} from '../../helpers/custom-validators/video-channels' 25} from '../../helpers/custom-validators/video-channels'
26import { sendDeleteActor } from '../../lib/activitypub/send' 26import { sendDeleteActor } from '../../lib/activitypub/send'
27import { AccountModel } from '../account/account' 27import { AccountModel, ScopeNames as AccountModelScopeNames } from '../account/account'
28import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' 28import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
29import { buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' 29import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
30import { VideoModel } from './video' 30import { VideoModel } from './video'
31import { CONSTRAINTS_FIELDS } from '../../initializers' 31import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
32import { ServerModel } from '../server/server' 32import { ServerModel } from '../server/server'
33import { DefineIndexesOptions } from 'sequelize' 33import { FindOptions, ModelIndexesOptions, Op } from 'sequelize'
34import { AvatarModel } from '../avatar/avatar'
35import { VideoPlaylistModel } from './video-playlist'
34 36
35// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 37// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
36const indexes: DefineIndexesOptions[] = [ 38const indexes: ModelIndexesOptions[] = [
37 buildTrigramSearchIndex('video_channel_name_trigram', 'name'), 39 buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
38 40
39 { 41 {
@@ -44,35 +46,62 @@ const indexes: DefineIndexesOptions[] = [
44 } 46 }
45] 47]
46 48
47enum ScopeNames { 49export enum ScopeNames {
48 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', 50 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
49 WITH_ACCOUNT = 'WITH_ACCOUNT', 51 WITH_ACCOUNT = 'WITH_ACCOUNT',
50 WITH_ACTOR = 'WITH_ACTOR', 52 WITH_ACTOR = 'WITH_ACTOR',
51 WITH_VIDEOS = 'WITH_VIDEOS' 53 WITH_VIDEOS = 'WITH_VIDEOS',
54 SUMMARY = 'SUMMARY'
52} 55}
53 56
54type AvailableForListOptions = { 57type AvailableForListOptions = {
55 actorId: number 58 actorId: number
56} 59}
57 60
58@DefaultScope({ 61@DefaultScope(() => ({
59 include: [ 62 include: [
60 { 63 {
61 model: () => ActorModel, 64 model: ActorModel,
62 required: true 65 required: true
63 } 66 }
64 ] 67 ]
65}) 68}))
66@Scopes({ 69@Scopes(() => ({
67 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { 70 [ScopeNames.SUMMARY]: (withAccount = false) => {
68 const actorIdNumber = parseInt(options.actorId + '', 10) 71 const base: FindOptions = {
72 attributes: [ 'name', 'description', 'id', 'actorId' ],
73 include: [
74 {
75 attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
76 model: ActorModel.unscoped(),
77 required: true,
78 include: [
79 {
80 attributes: [ 'host' ],
81 model: ServerModel.unscoped(),
82 required: false
83 },
84 {
85 model: AvatarModel.unscoped(),
86 required: false
87 }
88 ]
89 }
90 ]
91 }
92
93 if (withAccount === true) {
94 base.include.push({
95 model: AccountModel.scope(AccountModelScopeNames.SUMMARY),
96 required: true
97 })
98 }
69 99
100 return base
101 },
102 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
70 // Only list local channels OR channels that are on an instance followed by actorId 103 // Only list local channels OR channels that are on an instance followed by actorId
71 const inQueryInstanceFollow = '(' + 104 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
72 'SELECT "actor"."serverId" FROM "actorFollow" ' +
73 'INNER JOIN "actor" ON actor.id= "actorFollow"."targetActorId" ' +
74 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
75 ')'
76 105
77 return { 106 return {
78 include: [ 107 include: [
@@ -82,13 +111,13 @@ type AvailableForListOptions = {
82 }, 111 },
83 model: ActorModel, 112 model: ActorModel,
84 where: { 113 where: {
85 [Sequelize.Op.or]: [ 114 [Op.or]: [
86 { 115 {
87 serverId: null 116 serverId: null
88 }, 117 },
89 { 118 {
90 serverId: { 119 serverId: {
91 [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow) 120 [ Op.in ]: Sequelize.literal(inQueryInstanceFollow)
92 } 121 }
93 } 122 }
94 ] 123 ]
@@ -113,22 +142,22 @@ type AvailableForListOptions = {
113 [ScopeNames.WITH_ACCOUNT]: { 142 [ScopeNames.WITH_ACCOUNT]: {
114 include: [ 143 include: [
115 { 144 {
116 model: () => AccountModel, 145 model: AccountModel,
117 required: true 146 required: true
118 } 147 }
119 ] 148 ]
120 }, 149 },
121 [ScopeNames.WITH_VIDEOS]: { 150 [ScopeNames.WITH_VIDEOS]: {
122 include: [ 151 include: [
123 () => VideoModel 152 VideoModel
124 ] 153 ]
125 }, 154 },
126 [ScopeNames.WITH_ACTOR]: { 155 [ScopeNames.WITH_ACTOR]: {
127 include: [ 156 include: [
128 () => ActorModel 157 ActorModel
129 ] 158 ]
130 } 159 }
131}) 160}))
132@Table({ 161@Table({
133 tableName: 'videoChannel', 162 tableName: 'videoChannel',
134 indexes 163 indexes
@@ -142,13 +171,13 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
142 171
143 @AllowNull(true) 172 @AllowNull(true)
144 @Default(null) 173 @Default(null)
145 @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description')) 174 @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description', true))
146 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max)) 175 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
147 description: string 176 description: string
148 177
149 @AllowNull(true) 178 @AllowNull(true)
150 @Default(null) 179 @Default(null)
151 @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support')) 180 @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support', true))
152 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max)) 181 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
153 support: string 182 support: string
154 183
@@ -192,6 +221,15 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
192 }) 221 })
193 Videos: VideoModel[] 222 Videos: VideoModel[]
194 223
224 @HasMany(() => VideoPlaylistModel, {
225 foreignKey: {
226 allowNull: true
227 },
228 onDelete: 'CASCADE',
229 hooks: true
230 })
231 VideoPlaylists: VideoPlaylistModel[]
232
195 @BeforeDestroy 233 @BeforeDestroy
196 static async sendDeleteIfOwned (instance: VideoChannelModel, options) { 234 static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
197 if (!instance.Actor) { 235 if (!instance.Actor) {
@@ -274,7 +312,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
274 limit: options.count, 312 limit: options.count,
275 order: getSort(options.sort), 313 order: getSort(options.sort),
276 where: { 314 where: {
277 [Sequelize.Op.or]: [ 315 [Op.or]: [
278 Sequelize.literal( 316 Sequelize.literal(
279 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))' 317 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
280 ), 318 ),
@@ -320,7 +358,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
320 static loadByIdAndPopulateAccount (id: number) { 358 static loadByIdAndPopulateAccount (id: number) {
321 return VideoChannelModel.unscoped() 359 return VideoChannelModel.unscoped()
322 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) 360 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
323 .findById(id) 361 .findByPk(id)
324 } 362 }
325 363
326 static loadByIdAndAccount (id: number, accountId: number) { 364 static loadByIdAndAccount (id: number, accountId: number) {
@@ -339,7 +377,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
339 static loadAndPopulateAccount (id: number) { 377 static loadAndPopulateAccount (id: number) {
340 return VideoChannelModel.unscoped() 378 return VideoChannelModel.unscoped()
341 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ]) 379 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
342 .findById(id) 380 .findByPk(id)
343 } 381 }
344 382
345 static loadByUUIDAndPopulateAccount (uuid: string) { 383 static loadByUUIDAndPopulateAccount (uuid: string) {
@@ -378,6 +416,14 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
378 .findOne(query) 416 .findOne(query)
379 } 417 }
380 418
419 static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
420 const [ name, host ] = nameWithHost.split('@')
421
422 if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
423
424 return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
425 }
426
381 static loadLocalByNameAndPopulateAccount (name: string) { 427 static loadLocalByNameAndPopulateAccount (name: string) {
382 const query = { 428 const query = {
383 include: [ 429 include: [
@@ -431,7 +477,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
431 477
432 return VideoChannelModel.unscoped() 478 return VideoChannelModel.unscoped()
433 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEOS ]) 479 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEOS ])
434 .findById(id, options) 480 .findByPk(id, options)
435 } 481 }
436 482
437 toFormattedJSON (): VideoChannel { 483 toFormattedJSON (): VideoChannel {
@@ -452,6 +498,20 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
452 return Object.assign(actor, videoChannel) 498 return Object.assign(actor, videoChannel)
453 } 499 }
454 500
501 toFormattedSummaryJSON (): VideoChannelSummary {
502 const actor = this.Actor.toFormattedJSON()
503
504 return {
505 id: this.id,
506 uuid: actor.uuid,
507 name: actor.name,
508 displayName: this.getDisplayName(),
509 url: actor.url,
510 host: actor.host,
511 avatar: actor.avatar
512 }
513 }
514
455 toActivityPubObject (): ActivityPubActor { 515 toActivityPubObject (): ActivityPubActor {
456 const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel') 516 const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel')
457 517
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index 1163f9a0e..fee11ec5f 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,4 +1,3 @@
1import * as Sequelize from 'sequelize'
2import { 1import {
3 AllowNull, 2 AllowNull,
4 BeforeDestroy, 3 BeforeDestroy,
@@ -7,7 +6,6 @@ import {
7 CreatedAt, 6 CreatedAt,
8 DataType, 7 DataType,
9 ForeignKey, 8 ForeignKey,
10 IFindOptions,
11 Is, 9 Is,
12 Model, 10 Model,
13 Scopes, 11 Scopes,
@@ -18,7 +16,7 @@ import { ActivityTagObject } from '../../../shared/models/activitypub/objects/co
18import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 16import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
19import { VideoComment } from '../../../shared/models/videos/video-comment.model' 17import { VideoComment } from '../../../shared/models/videos/video-comment.model'
20import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 18import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
21import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers' 19import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
22import { sendDeleteVideoComment } from '../../lib/activitypub/send' 20import { sendDeleteVideoComment } from '../../lib/activitypub/send'
23import { AccountModel } from '../account/account' 21import { AccountModel } from '../account/account'
24import { ActorModel } from '../activitypub/actor' 22import { ActorModel } from '../activitypub/actor'
@@ -32,6 +30,7 @@ import { UserModel } from '../account/user'
32import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' 30import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
33import { regexpCapture } from '../../helpers/regexp' 31import { regexpCapture } from '../../helpers/regexp'
34import { uniq } from 'lodash' 32import { uniq } from 'lodash'
33import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
35 34
36enum ScopeNames { 35enum ScopeNames {
37 WITH_ACCOUNT = 'WITH_ACCOUNT', 36 WITH_ACCOUNT = 'WITH_ACCOUNT',
@@ -40,7 +39,7 @@ enum ScopeNames {
40 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API' 39 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
41} 40}
42 41
43@Scopes({ 42@Scopes(() => ({
44 [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => { 43 [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
45 return { 44 return {
46 attributes: { 45 attributes: {
@@ -64,22 +63,22 @@ enum ScopeNames {
64 ] 63 ]
65 ] 64 ]
66 } 65 }
67 } 66 } as FindOptions
68 }, 67 },
69 [ScopeNames.WITH_ACCOUNT]: { 68 [ScopeNames.WITH_ACCOUNT]: {
70 include: [ 69 include: [
71 { 70 {
72 model: () => AccountModel, 71 model: AccountModel,
73 include: [ 72 include: [
74 { 73 {
75 model: () => ActorModel, 74 model: ActorModel,
76 include: [ 75 include: [
77 { 76 {
78 model: () => ServerModel, 77 model: ServerModel,
79 required: false 78 required: false
80 }, 79 },
81 { 80 {
82 model: () => AvatarModel, 81 model: AvatarModel,
83 required: false 82 required: false
84 } 83 }
85 ] 84 ]
@@ -91,7 +90,7 @@ enum ScopeNames {
91 [ScopeNames.WITH_IN_REPLY_TO]: { 90 [ScopeNames.WITH_IN_REPLY_TO]: {
92 include: [ 91 include: [
93 { 92 {
94 model: () => VideoCommentModel, 93 model: VideoCommentModel,
95 as: 'InReplyToVideoComment' 94 as: 'InReplyToVideoComment'
96 } 95 }
97 ] 96 ]
@@ -99,19 +98,19 @@ enum ScopeNames {
99 [ScopeNames.WITH_VIDEO]: { 98 [ScopeNames.WITH_VIDEO]: {
100 include: [ 99 include: [
101 { 100 {
102 model: () => VideoModel, 101 model: VideoModel,
103 required: true, 102 required: true,
104 include: [ 103 include: [
105 { 104 {
106 model: () => VideoChannelModel.unscoped(), 105 model: VideoChannelModel.unscoped(),
107 required: true, 106 required: true,
108 include: [ 107 include: [
109 { 108 {
110 model: () => AccountModel, 109 model: AccountModel,
111 required: true, 110 required: true,
112 include: [ 111 include: [
113 { 112 {
114 model: () => ActorModel, 113 model: ActorModel,
115 required: true 114 required: true
116 } 115 }
117 ] 116 ]
@@ -122,7 +121,7 @@ enum ScopeNames {
122 } 121 }
123 ] 122 ]
124 } 123 }
125}) 124}))
126@Table({ 125@Table({
127 tableName: 'videoComment', 126 tableName: 'videoComment',
128 indexes: [ 127 indexes: [
@@ -244,8 +243,8 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
244 } 243 }
245 } 244 }
246 245
247 static loadById (id: number, t?: Sequelize.Transaction) { 246 static loadById (id: number, t?: Transaction) {
248 const query: IFindOptions<VideoCommentModel> = { 247 const query: FindOptions = {
249 where: { 248 where: {
250 id 249 id
251 } 250 }
@@ -256,8 +255,8 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
256 return VideoCommentModel.findOne(query) 255 return VideoCommentModel.findOne(query)
257 } 256 }
258 257
259 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) { 258 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Transaction) {
260 const query: IFindOptions<VideoCommentModel> = { 259 const query: FindOptions = {
261 where: { 260 where: {
262 id 261 id
263 } 262 }
@@ -270,8 +269,8 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
270 .findOne(query) 269 .findOne(query)
271 } 270 }
272 271
273 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) { 272 static loadByUrlAndPopulateAccount (url: string, t?: Transaction) {
274 const query: IFindOptions<VideoCommentModel> = { 273 const query: FindOptions = {
275 where: { 274 where: {
276 url 275 url
277 } 276 }
@@ -282,8 +281,8 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
282 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query) 281 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
283 } 282 }
284 283
285 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) { 284 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Transaction) {
286 const query: IFindOptions<VideoCommentModel> = { 285 const query: FindOptions = {
287 where: { 286 where: {
288 url 287 url
289 } 288 }
@@ -307,15 +306,14 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
307 videoId, 306 videoId,
308 inReplyToCommentId: null, 307 inReplyToCommentId: null,
309 accountId: { 308 accountId: {
310 [Sequelize.Op.notIn]: Sequelize.literal( 309 [Op.notIn]: Sequelize.literal(
311 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' 310 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
312 ) 311 )
313 } 312 }
314 } 313 }
315 } 314 }
316 315
317 // FIXME: typings 316 const scopes: (string | ScopeOptions)[] = [
318 const scopes: any[] = [
319 ScopeNames.WITH_ACCOUNT, 317 ScopeNames.WITH_ACCOUNT,
320 { 318 {
321 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ] 319 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
@@ -336,15 +334,15 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
336 const userAccountId = user ? user.Account.id : undefined 334 const userAccountId = user ? user.Account.id : undefined
337 335
338 const query = { 336 const query = {
339 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ], 337 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
340 where: { 338 where: {
341 videoId, 339 videoId,
342 [ Sequelize.Op.or ]: [ 340 [ Op.or ]: [
343 { id: threadId }, 341 { id: threadId },
344 { originCommentId: threadId } 342 { originCommentId: threadId }
345 ], 343 ],
346 accountId: { 344 accountId: {
347 [Sequelize.Op.notIn]: Sequelize.literal( 345 [Op.notIn]: Sequelize.literal(
348 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' 346 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
349 ) 347 )
350 } 348 }
@@ -366,12 +364,12 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
366 }) 364 })
367 } 365 }
368 366
369 static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') { 367 static listThreadParentComments (comment: VideoCommentModel, t: Transaction, order: 'ASC' | 'DESC' = 'ASC') {
370 const query = { 368 const query = {
371 order: [ [ 'createdAt', order ] ], 369 order: [ [ 'createdAt', order ] ] as Order,
372 where: { 370 where: {
373 id: { 371 id: {
374 [ Sequelize.Op.in ]: Sequelize.literal('(' + 372 [ Op.in ]: Sequelize.literal('(' +
375 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' + 373 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
376 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` + 374 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
377 'UNION ' + 375 'UNION ' +
@@ -380,7 +378,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
380 ') ' + 378 ') ' +
381 'SELECT id FROM children' + 379 'SELECT id FROM children' +
382 ')'), 380 ')'),
383 [ Sequelize.Op.ne ]: comment.id 381 [ Op.ne ]: comment.id
384 } 382 }
385 }, 383 },
386 transaction: t 384 transaction: t
@@ -391,9 +389,9 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
391 .findAll(query) 389 .findAll(query)
392 } 390 }
393 391
394 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') { 392 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Transaction, order: 'ASC' | 'DESC' = 'ASC') {
395 const query = { 393 const query = {
396 order: [ [ 'createdAt', order ] ], 394 order: [ [ 'createdAt', order ] ] as Order,
397 offset: start, 395 offset: start,
398 limit: count, 396 limit: count,
399 where: { 397 where: {
@@ -407,7 +405,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
407 405
408 static listForFeed (start: number, count: number, videoId?: number) { 406 static listForFeed (start: number, count: number, videoId?: number) {
409 const query = { 407 const query = {
410 order: [ [ 'createdAt', 'DESC' ] ], 408 order: [ [ 'createdAt', 'DESC' ] ] as Order,
411 offset: start, 409 offset: start,
412 limit: count, 410 limit: count,
413 where: {}, 411 where: {},
@@ -453,6 +451,19 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
453 } 451 }
454 } 452 }
455 453
454 static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
455 const query = {
456 where: {
457 updatedAt: {
458 [Op.lt]: beforeUpdatedAt
459 },
460 videoId
461 }
462 }
463
464 return VideoCommentModel.destroy(query)
465 }
466
456 getCommentStaticPath () { 467 getCommentStaticPath () {
457 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId() 468 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
458 } 469 }
@@ -469,7 +480,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
469 let result: string[] = [] 480 let result: string[] = []
470 481
471 const localMention = `@(${actorNameAlphabet}+)` 482 const localMention = `@(${actorNameAlphabet}+)`
472 const remoteMention = `${localMention}@${CONFIG.WEBSERVER.HOST}` 483 const remoteMention = `${localMention}@${WEBSERVER.HOST}`
473 484
474 const mentionRegex = this.isOwned() 485 const mentionRegex = this.isOwned()
475 ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions? 486 ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions?
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 1f1b76c1e..2203a7aba 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -19,10 +19,11 @@ import {
19 isVideoFileSizeValid, 19 isVideoFileSizeValid,
20 isVideoFPSResolutionValid 20 isVideoFPSResolutionValid
21} from '../../helpers/custom-validators/videos' 21} from '../../helpers/custom-validators/videos'
22import { throwIfNotValid } from '../utils' 22import { parseAggregateResult, throwIfNotValid } from '../utils'
23import { VideoModel } from './video' 23import { VideoModel } from './video'
24import * as Sequelize from 'sequelize'
25import { VideoRedundancyModel } from '../redundancy/video-redundancy' 24import { VideoRedundancyModel } from '../redundancy/video-redundancy'
25import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
26import { FindOptions, QueryTypes, Transaction } from 'sequelize'
26 27
27@Table({ 28@Table({
28 tableName: 'videoFile', 29 tableName: 'videoFile',
@@ -62,7 +63,7 @@ export class VideoFileModel extends Model<VideoFileModel> {
62 extname: string 63 extname: string
63 64
64 @AllowNull(false) 65 @AllowNull(false)
65 @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash')) 66 @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
66 @Column 67 @Column
67 infoHash: string 68 infoHash: string
68 69
@@ -86,25 +87,23 @@ export class VideoFileModel extends Model<VideoFileModel> {
86 87
87 @HasMany(() => VideoRedundancyModel, { 88 @HasMany(() => VideoRedundancyModel, {
88 foreignKey: { 89 foreignKey: {
89 allowNull: false 90 allowNull: true
90 }, 91 },
91 onDelete: 'CASCADE', 92 onDelete: 'CASCADE',
92 hooks: true 93 hooks: true
93 }) 94 })
94 RedundancyVideos: VideoRedundancyModel[] 95 RedundancyVideos: VideoRedundancyModel[]
95 96
96 static isInfohashExists (infoHash: string) { 97 static doesInfohashExist (infoHash: string) {
97 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' 98 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
98 const options = { 99 const options = {
99 type: Sequelize.QueryTypes.SELECT, 100 type: QueryTypes.SELECT,
100 bind: { infoHash }, 101 bind: { infoHash },
101 raw: true 102 raw: true
102 } 103 }
103 104
104 return VideoModel.sequelize.query(query, options) 105 return VideoModel.sequelize.query(query, options)
105 .then(results => { 106 .then(results => results.length === 1)
106 return results.length === 1
107 })
108 } 107 }
109 108
110 static loadWithVideo (id: number) { 109 static loadWithVideo (id: number) {
@@ -117,11 +116,34 @@ export class VideoFileModel extends Model<VideoFileModel> {
117 ] 116 ]
118 } 117 }
119 118
120 return VideoFileModel.findById(id, options) 119 return VideoFileModel.findByPk(id, options)
121 } 120 }
122 121
123 static async getStats () { 122 static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) {
124 let totalLocalVideoFilesSize = await VideoFileModel.sum('size', { 123 const query = {
124 include: [
125 {
126 model: VideoModel.unscoped(),
127 required: true,
128 include: [
129 {
130 model: VideoStreamingPlaylistModel.unscoped(),
131 required: true,
132 where: {
133 id: streamingPlaylistId
134 }
135 }
136 ]
137 }
138 ],
139 transaction
140 }
141
142 return VideoFileModel.findAll(query)
143 }
144
145 static getStats () {
146 const query: FindOptions = {
125 include: [ 147 include: [
126 { 148 {
127 attributes: [], 149 attributes: [],
@@ -131,13 +153,12 @@ export class VideoFileModel extends Model<VideoFileModel> {
131 } 153 }
132 } 154 }
133 ] 155 ]
134 } as any)
135 // Sequelize could return null...
136 if (!totalLocalVideoFilesSize) totalLocalVideoFilesSize = 0
137
138 return {
139 totalLocalVideoFilesSize
140 } 156 }
157
158 return VideoFileModel.aggregate('size', 'SUM', query)
159 .then(result => ({
160 totalLocalVideoFilesSize: parseAggregateResult(result)
161 }))
141 } 162 }
142 163
143 hasSameUniqueKeysThan (other: VideoFileModel) { 164 hasSameUniqueKeysThan (other: VideoFileModel) {
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
index de0747f22..b947eb16f 100644
--- a/server/models/video/video-format-utils.ts
+++ b/server/models/video/video-format-utils.ts
@@ -1,8 +1,13 @@
1import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' 1import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
2import { VideoModel } from './video' 2import { VideoModel } from './video'
3import { VideoFileModel } from './video-file' 3import { VideoFileModel } from './video-file'
4import { ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects' 4import {
5import { CONFIG, MIMETYPES, THUMBNAILS_SIZE } from '../../initializers' 5 ActivityPlaylistInfohashesObject,
6 ActivityPlaylistSegmentHashesObject,
7 ActivityUrlObject,
8 VideoTorrentObject
9} from '../../../shared/models/activitypub/objects'
10import { MIMETYPES, WEBSERVER } from '../../initializers/constants'
6import { VideoCaptionModel } from './video-caption' 11import { VideoCaptionModel } from './video-caption'
7import { 12import {
8 getVideoCommentsActivityPubUrl, 13 getVideoCommentsActivityPubUrl,
@@ -11,6 +16,8 @@ import {
11 getVideoSharesActivityPubUrl 16 getVideoSharesActivityPubUrl
12} from '../../lib/activitypub' 17} from '../../lib/activitypub'
13import { isArray } from '../../helpers/custom-validators/misc' 18import { isArray } from '../../helpers/custom-validators/misc'
19import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
20import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
14 21
15export type VideoFormattingJSONOptions = { 22export type VideoFormattingJSONOptions = {
16 completeDescription?: boolean 23 completeDescription?: boolean
@@ -19,12 +26,10 @@ export type VideoFormattingJSONOptions = {
19 waitTranscoding?: boolean, 26 waitTranscoding?: boolean,
20 scheduledUpdate?: boolean, 27 scheduledUpdate?: boolean,
21 blacklistInfo?: boolean 28 blacklistInfo?: boolean
29 playlistInfo?: boolean
22 } 30 }
23} 31}
24function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video { 32function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
25 const formattedAccount = video.VideoChannel.Account.toFormattedJSON()
26 const formattedVideoChannel = video.VideoChannel.toFormattedJSON()
27
28 const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined 33 const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined
29 34
30 const videoObject: Video = { 35 const videoObject: Video = {
@@ -54,30 +59,16 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
54 views: video.views, 59 views: video.views,
55 likes: video.likes, 60 likes: video.likes,
56 dislikes: video.dislikes, 61 dislikes: video.dislikes,
57 thumbnailPath: video.getThumbnailStaticPath(), 62 thumbnailPath: video.getMiniatureStaticPath(),
58 previewPath: video.getPreviewStaticPath(), 63 previewPath: video.getPreviewStaticPath(),
59 embedPath: video.getEmbedStaticPath(), 64 embedPath: video.getEmbedStaticPath(),
60 createdAt: video.createdAt, 65 createdAt: video.createdAt,
61 updatedAt: video.updatedAt, 66 updatedAt: video.updatedAt,
62 publishedAt: video.publishedAt, 67 publishedAt: video.publishedAt,
63 account: { 68 originallyPublishedAt: video.originallyPublishedAt,
64 id: formattedAccount.id, 69
65 uuid: formattedAccount.uuid, 70 account: video.VideoChannel.Account.toFormattedSummaryJSON(),
66 name: formattedAccount.name, 71 channel: video.VideoChannel.toFormattedSummaryJSON(),
67 displayName: formattedAccount.displayName,
68 url: formattedAccount.url,
69 host: formattedAccount.host,
70 avatar: formattedAccount.avatar
71 },
72 channel: {
73 id: formattedVideoChannel.id,
74 uuid: formattedVideoChannel.uuid,
75 name: formattedVideoChannel.name,
76 displayName: formattedVideoChannel.displayName,
77 url: formattedVideoChannel.url,
78 host: formattedVideoChannel.host,
79 avatar: formattedVideoChannel.avatar
80 },
81 72
82 userHistory: userHistory ? { 73 userHistory: userHistory ? {
83 currentTime: userHistory.currentTime 74 currentTime: userHistory.currentTime
@@ -107,6 +98,17 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
107 videoObject.blacklisted = !!video.VideoBlacklist 98 videoObject.blacklisted = !!video.VideoBlacklist
108 videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null 99 videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
109 } 100 }
101
102 if (options.additionalAttributes.playlistInfo === true) {
103 // We filtered on a specific videoId/videoPlaylistId, that is unique
104 const playlistElement = video.VideoPlaylistElements[0]
105
106 videoObject.playlistElement = {
107 position: playlistElement.position,
108 startTimestamp: playlistElement.startTimestamp,
109 stopTimestamp: playlistElement.stopTimestamp
110 }
111 }
110 } 112 }
111 113
112 return videoObject 114 return videoObject
@@ -120,7 +122,12 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
120 } 122 }
121 }) 123 })
122 124
125 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
126
123 const tags = video.Tags ? video.Tags.map(t => t.name) : [] 127 const tags = video.Tags ? video.Tags.map(t => t.name) : []
128
129 const streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
130
124 const detailsJson = { 131 const detailsJson = {
125 support: video.support, 132 support: video.support,
126 descriptionPath: video.getDescriptionAPIPath(), 133 descriptionPath: video.getDescriptionAPIPath(),
@@ -128,12 +135,17 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
128 account: video.VideoChannel.Account.toFormattedJSON(), 135 account: video.VideoChannel.Account.toFormattedJSON(),
129 tags, 136 tags,
130 commentsEnabled: video.commentsEnabled, 137 commentsEnabled: video.commentsEnabled,
138 downloadEnabled: video.downloadEnabled,
131 waitTranscoding: video.waitTranscoding, 139 waitTranscoding: video.waitTranscoding,
132 state: { 140 state: {
133 id: video.state, 141 id: video.state,
134 label: VideoModel.getStateLabel(video.state) 142 label: VideoModel.getStateLabel(video.state)
135 }, 143 },
136 files: [] 144
145 trackerUrls: video.getTrackerUrls(baseUrlHttp, baseUrlWs),
146
147 files: [],
148 streamingPlaylists
137 } 149 }
138 150
139 // Format and sort video files 151 // Format and sort video files
@@ -142,6 +154,25 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
142 return Object.assign(formattedJson, detailsJson) 154 return Object.assign(formattedJson, detailsJson)
143} 155}
144 156
157function streamingPlaylistsModelToFormattedJSON (video: VideoModel, playlists: VideoStreamingPlaylistModel[]): VideoStreamingPlaylist[] {
158 if (isArray(playlists) === false) return []
159
160 return playlists
161 .map(playlist => {
162 const redundancies = isArray(playlist.RedundancyVideos)
163 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
164 : []
165
166 return {
167 id: playlist.id,
168 type: playlist.type,
169 playlistUrl: playlist.playlistUrl,
170 segmentsSha256Url: playlist.segmentsSha256Url,
171 redundancies
172 } as VideoStreamingPlaylist
173 })
174}
175
145function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] { 176function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
146 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() 177 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
147 178
@@ -232,12 +263,34 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
232 }) 263 })
233 } 264 }
234 265
266 for (const playlist of (video.VideoStreamingPlaylists || [])) {
267 let tag: (ActivityPlaylistSegmentHashesObject | ActivityPlaylistInfohashesObject)[]
268
269 tag = playlist.p2pMediaLoaderInfohashes
270 .map(i => ({ type: 'Infohash' as 'Infohash', name: i }))
271 tag.push({
272 type: 'Link',
273 name: 'sha256',
274 mimeType: 'application/json' as 'application/json',
275 mediaType: 'application/json' as 'application/json',
276 href: playlist.segmentsSha256Url
277 })
278
279 url.push({
280 type: 'Link',
281 mimeType: 'application/x-mpegURL' as 'application/x-mpegURL',
282 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
283 href: playlist.playlistUrl,
284 tag
285 })
286 }
287
235 // Add video url too 288 // Add video url too
236 url.push({ 289 url.push({
237 type: 'Link', 290 type: 'Link',
238 mimeType: 'text/html', 291 mimeType: 'text/html',
239 mediaType: 'text/html', 292 mediaType: 'text/html',
240 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid 293 href: WEBSERVER.URL + '/videos/watch/' + video.uuid
241 }) 294 })
242 295
243 const subtitleLanguage = [] 296 const subtitleLanguage = []
@@ -248,6 +301,8 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
248 }) 301 })
249 } 302 }
250 303
304 const miniature = video.getMiniature()
305
251 return { 306 return {
252 type: 'Video' as 'Video', 307 type: 'Video' as 'Video',
253 id: video.url, 308 id: video.url,
@@ -263,7 +318,9 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
263 waitTranscoding: video.waitTranscoding, 318 waitTranscoding: video.waitTranscoding,
264 state: video.state, 319 state: video.state,
265 commentsEnabled: video.commentsEnabled, 320 commentsEnabled: video.commentsEnabled,
321 downloadEnabled: video.downloadEnabled,
266 published: video.publishedAt.toISOString(), 322 published: video.publishedAt.toISOString(),
323 originallyPublishedAt: video.originallyPublishedAt ? video.originallyPublishedAt.toISOString() : null,
267 updated: video.updatedAt.toISOString(), 324 updated: video.updatedAt.toISOString(),
268 mediaType: 'text/markdown', 325 mediaType: 'text/markdown',
269 content: video.getTruncatedDescription(), 326 content: video.getTruncatedDescription(),
@@ -271,10 +328,10 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
271 subtitleLanguage, 328 subtitleLanguage,
272 icon: { 329 icon: {
273 type: 'Image', 330 type: 'Image',
274 url: video.getThumbnailUrl(baseUrlHttp), 331 url: miniature.getFileUrl(),
275 mediaType: 'image/jpeg', 332 mediaType: 'image/jpeg',
276 width: THUMBNAILS_SIZE.width, 333 width: miniature.width,
277 height: THUMBNAILS_SIZE.height 334 height: miniature.height
278 }, 335 },
279 url, 336 url,
280 likes: getVideoLikesActivityPubUrl(video), 337 likes: getVideoLikesActivityPubUrl(video),
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
index c723e57c0..480a671c8 100644
--- a/server/models/video/video-import.ts
+++ b/server/models/video/video-import.ts
@@ -13,7 +13,7 @@ import {
13 Table, 13 Table,
14 UpdatedAt 14 UpdatedAt
15} from 'sequelize-typescript' 15} from 'sequelize-typescript'
16import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers' 16import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
17import { getSort, throwIfNotValid } from '../utils' 17import { getSort, throwIfNotValid } from '../utils'
18import { ScopeNames as VideoModelScopeNames, VideoModel } from './video' 18import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
19import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports' 19import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports'
@@ -21,18 +21,18 @@ import { VideoImport, VideoImportState } from '../../../shared'
21import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos' 21import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
22import { UserModel } from '../account/user' 22import { UserModel } from '../account/user'
23 23
24@DefaultScope({ 24@DefaultScope(() => ({
25 include: [ 25 include: [
26 { 26 {
27 model: () => UserModel.unscoped(), 27 model: UserModel.unscoped(),
28 required: true 28 required: true
29 }, 29 },
30 { 30 {
31 model: () => VideoModel.scope([ VideoModelScopeNames.WITH_ACCOUNT_DETAILS, VideoModelScopeNames.WITH_TAGS]), 31 model: VideoModel.scope([ VideoModelScopeNames.WITH_ACCOUNT_DETAILS, VideoModelScopeNames.WITH_TAGS]),
32 required: false 32 required: false
33 } 33 }
34 ] 34 ]
35}) 35}))
36 36
37@Table({ 37@Table({
38 tableName: 'videoImport', 38 tableName: 'videoImport',
@@ -55,13 +55,13 @@ export class VideoImportModel extends Model<VideoImportModel> {
55 55
56 @AllowNull(true) 56 @AllowNull(true)
57 @Default(null) 57 @Default(null)
58 @Is('VideoImportTargetUrl', value => throwIfNotValid(value, isVideoImportTargetUrlValid, 'targetUrl')) 58 @Is('VideoImportTargetUrl', value => throwIfNotValid(value, isVideoImportTargetUrlValid, 'targetUrl', true))
59 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max)) 59 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max))
60 targetUrl: string 60 targetUrl: string
61 61
62 @AllowNull(true) 62 @AllowNull(true)
63 @Default(null) 63 @Default(null)
64 @Is('VideoImportMagnetUri', value => throwIfNotValid(value, isVideoMagnetUriValid, 'magnetUri')) 64 @Is('VideoImportMagnetUri', value => throwIfNotValid(value, isVideoMagnetUriValid, 'magnetUri', true))
65 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max)) // Use the same constraints than URLs 65 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max)) // Use the same constraints than URLs
66 magnetUri: string 66 magnetUri: string
67 67
@@ -115,7 +115,7 @@ export class VideoImportModel extends Model<VideoImportModel> {
115 } 115 }
116 116
117 static loadAndPopulateVideo (id: number) { 117 static loadAndPopulateVideo (id: number) {
118 return VideoImportModel.findById(id) 118 return VideoImportModel.findByPk(id)
119 } 119 }
120 120
121 static listUserVideoImportsForApi (userId: number, start: number, count: number, sort: string) { 121 static listUserVideoImportsForApi (userId: number, start: number, count: number, sort: string) {
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
new file mode 100644
index 000000000..eeb3d6bbd
--- /dev/null
+++ b/server/models/video/video-playlist-element.ts
@@ -0,0 +1,230 @@
1import {
2 AllowNull,
3 BelongsTo,
4 Column,
5 CreatedAt,
6 DataType,
7 Default,
8 ForeignKey,
9 Is,
10 IsInt,
11 Min,
12 Model,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import { VideoModel } from './video'
17import { VideoPlaylistModel } from './video-playlist'
18import { getSort, throwIfNotValid } from '../utils'
19import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
20import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
21import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
22import * as validator from 'validator'
23import { AggregateOptions, Op, Sequelize, Transaction } from 'sequelize'
24
25@Table({
26 tableName: 'videoPlaylistElement',
27 indexes: [
28 {
29 fields: [ 'videoPlaylistId' ]
30 },
31 {
32 fields: [ 'videoId' ]
33 },
34 {
35 fields: [ 'videoPlaylistId', 'videoId' ],
36 unique: true
37 },
38 {
39 fields: [ 'url' ],
40 unique: true
41 }
42 ]
43})
44export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel> {
45 @CreatedAt
46 createdAt: Date
47
48 @UpdatedAt
49 updatedAt: Date
50
51 @AllowNull(false)
52 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
53 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
54 url: string
55
56 @AllowNull(false)
57 @Default(1)
58 @IsInt
59 @Min(1)
60 @Column
61 position: number
62
63 @AllowNull(true)
64 @IsInt
65 @Min(0)
66 @Column
67 startTimestamp: number
68
69 @AllowNull(true)
70 @IsInt
71 @Min(0)
72 @Column
73 stopTimestamp: number
74
75 @ForeignKey(() => VideoPlaylistModel)
76 @Column
77 videoPlaylistId: number
78
79 @BelongsTo(() => VideoPlaylistModel, {
80 foreignKey: {
81 allowNull: false
82 },
83 onDelete: 'CASCADE'
84 })
85 VideoPlaylist: VideoPlaylistModel
86
87 @ForeignKey(() => VideoModel)
88 @Column
89 videoId: number
90
91 @BelongsTo(() => VideoModel, {
92 foreignKey: {
93 allowNull: false
94 },
95 onDelete: 'CASCADE'
96 })
97 Video: VideoModel
98
99 static deleteAllOf (videoPlaylistId: number, transaction?: Transaction) {
100 const query = {
101 where: {
102 videoPlaylistId
103 },
104 transaction
105 }
106
107 return VideoPlaylistElementModel.destroy(query)
108 }
109
110 static loadByPlaylistAndVideo (videoPlaylistId: number, videoId: number) {
111 const query = {
112 where: {
113 videoPlaylistId,
114 videoId
115 }
116 }
117
118 return VideoPlaylistElementModel.findOne(query)
119 }
120
121 static loadByPlaylistAndVideoForAP (playlistId: number | string, videoId: number | string) {
122 const playlistWhere = validator.isUUID('' + playlistId) ? { uuid: playlistId } : { id: playlistId }
123 const videoWhere = validator.isUUID('' + videoId) ? { uuid: videoId } : { id: videoId }
124
125 const query = {
126 include: [
127 {
128 attributes: [ 'privacy' ],
129 model: VideoPlaylistModel.unscoped(),
130 where: playlistWhere
131 },
132 {
133 attributes: [ 'url' ],
134 model: VideoModel.unscoped(),
135 where: videoWhere
136 }
137 ]
138 }
139
140 return VideoPlaylistElementModel.findOne(query)
141 }
142
143 static listUrlsOfForAP (videoPlaylistId: number, start: number, count: number, t?: Transaction) {
144 const query = {
145 attributes: [ 'url' ],
146 offset: start,
147 limit: count,
148 order: getSort('position'),
149 where: {
150 videoPlaylistId
151 },
152 transaction: t
153 }
154
155 return VideoPlaylistElementModel
156 .findAndCountAll(query)
157 .then(({ rows, count }) => {
158 return { total: count, data: rows.map(e => e.url) }
159 })
160 }
161
162 static getNextPositionOf (videoPlaylistId: number, transaction?: Transaction) {
163 const query: AggregateOptions<number> = {
164 where: {
165 videoPlaylistId
166 },
167 transaction
168 }
169
170 return VideoPlaylistElementModel.max('position', query)
171 .then(position => position ? position + 1 : 1)
172 }
173
174 static reassignPositionOf (
175 videoPlaylistId: number,
176 firstPosition: number,
177 endPosition: number,
178 newPosition: number,
179 transaction?: Transaction
180 ) {
181 const query = {
182 where: {
183 videoPlaylistId,
184 position: {
185 [Op.gte]: firstPosition,
186 [Op.lte]: endPosition
187 }
188 },
189 transaction,
190 validate: false // We use a literal to update the position
191 }
192
193 return VideoPlaylistElementModel.update({ position: Sequelize.literal(`${newPosition} + "position" - ${firstPosition}`) }, query)
194 }
195
196 static increasePositionOf (
197 videoPlaylistId: number,
198 fromPosition: number,
199 toPosition?: number,
200 by = 1,
201 transaction?: Transaction
202 ) {
203 const query = {
204 where: {
205 videoPlaylistId,
206 position: {
207 [Op.gte]: fromPosition
208 }
209 },
210 transaction
211 }
212
213 return VideoPlaylistElementModel.increment({ position: by }, query)
214 }
215
216 toActivityPubObject (): PlaylistElementObject {
217 const base: PlaylistElementObject = {
218 id: this.url,
219 type: 'PlaylistElement',
220
221 url: this.Video.url,
222 position: this.position
223 }
224
225 if (this.startTimestamp) base.startTimestamp = this.startTimestamp
226 if (this.stopTimestamp) base.stopTimestamp = this.stopTimestamp
227
228 return base
229 }
230}
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
new file mode 100644
index 000000000..63b4a0715
--- /dev/null
+++ b/server/models/video/video-playlist.ts
@@ -0,0 +1,531 @@
1import {
2 AllowNull,
3 BelongsTo,
4 Column,
5 CreatedAt,
6 DataType,
7 Default,
8 ForeignKey,
9 HasMany,
10 HasOne,
11 Is,
12 IsUUID,
13 Model,
14 Scopes,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
18import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
19import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, isOutdated, throwIfNotValid } from '../utils'
20import {
21 isVideoPlaylistDescriptionValid,
22 isVideoPlaylistNameValid,
23 isVideoPlaylistPrivacyValid
24} from '../../helpers/custom-validators/video-playlists'
25import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
26import {
27 ACTIVITY_PUB,
28 CONSTRAINTS_FIELDS,
29 STATIC_PATHS,
30 THUMBNAILS_SIZE,
31 VIDEO_PLAYLIST_PRIVACIES,
32 VIDEO_PLAYLIST_TYPES,
33 WEBSERVER
34} from '../../initializers/constants'
35import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
36import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
37import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
38import { join } from 'path'
39import { VideoPlaylistElementModel } from './video-playlist-element'
40import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
41import { activityPubCollectionPagination } from '../../helpers/activitypub'
42import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
43import { ThumbnailModel } from './thumbnail'
44import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
45import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
46
47enum ScopeNames {
48 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
49 WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
50 WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY',
51 WITH_ACCOUNT = 'WITH_ACCOUNT',
52 WITH_THUMBNAIL = 'WITH_THUMBNAIL',
53 WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
54}
55
56type AvailableForListOptions = {
57 followerActorId: number
58 type?: VideoPlaylistType
59 accountId?: number
60 videoChannelId?: number
61 privateAndUnlisted?: boolean
62}
63
64@Scopes(() => ({
65 [ ScopeNames.WITH_THUMBNAIL ]: {
66 include: [
67 {
68 model: ThumbnailModel,
69 required: false
70 }
71 ]
72 },
73 [ ScopeNames.WITH_VIDEOS_LENGTH ]: {
74 attributes: {
75 include: [
76 [
77 literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
78 'videosLength'
79 ]
80 ]
81 }
82 } as FindOptions,
83 [ ScopeNames.WITH_ACCOUNT ]: {
84 include: [
85 {
86 model: AccountModel,
87 required: true
88 }
89 ]
90 },
91 [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY ]: {
92 include: [
93 {
94 model: AccountModel.scope(AccountScopeNames.SUMMARY),
95 required: true
96 },
97 {
98 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
99 required: false
100 }
101 ]
102 },
103 [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL ]: {
104 include: [
105 {
106 model: AccountModel,
107 required: true
108 },
109 {
110 model: VideoChannelModel,
111 required: false
112 }
113 ]
114 },
115 [ ScopeNames.AVAILABLE_FOR_LIST ]: (options: AvailableForListOptions) => {
116 // Only list local playlists OR playlists that are on an instance followed by actorId
117 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
118 const actorWhere = {
119 [ Op.or ]: [
120 {
121 serverId: null
122 },
123 {
124 serverId: {
125 [ Op.in ]: literal(inQueryInstanceFollow)
126 }
127 }
128 ]
129 }
130
131 const whereAnd: WhereOptions[] = []
132
133 if (options.privateAndUnlisted !== true) {
134 whereAnd.push({
135 privacy: VideoPlaylistPrivacy.PUBLIC
136 })
137 }
138
139 if (options.accountId) {
140 whereAnd.push({
141 ownerAccountId: options.accountId
142 })
143 }
144
145 if (options.videoChannelId) {
146 whereAnd.push({
147 videoChannelId: options.videoChannelId
148 })
149 }
150
151 if (options.type) {
152 whereAnd.push({
153 type: options.type
154 })
155 }
156
157 const where = {
158 [Op.and]: whereAnd
159 }
160
161 const accountScope = {
162 method: [ AccountScopeNames.SUMMARY, actorWhere ]
163 }
164
165 return {
166 where,
167 include: [
168 {
169 model: AccountModel.scope(accountScope),
170 required: true
171 },
172 {
173 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
174 required: false
175 }
176 ]
177 } as FindOptions
178 }
179}))
180
181@Table({
182 tableName: 'videoPlaylist',
183 indexes: [
184 {
185 fields: [ 'ownerAccountId' ]
186 },
187 {
188 fields: [ 'videoChannelId' ]
189 },
190 {
191 fields: [ 'url' ],
192 unique: true
193 }
194 ]
195})
196export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
197 @CreatedAt
198 createdAt: Date
199
200 @UpdatedAt
201 updatedAt: Date
202
203 @AllowNull(false)
204 @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name'))
205 @Column
206 name: string
207
208 @AllowNull(true)
209 @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true))
210 @Column
211 description: string
212
213 @AllowNull(false)
214 @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy'))
215 @Column
216 privacy: VideoPlaylistPrivacy
217
218 @AllowNull(false)
219 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
220 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
221 url: string
222
223 @AllowNull(false)
224 @Default(DataType.UUIDV4)
225 @IsUUID(4)
226 @Column(DataType.UUID)
227 uuid: string
228
229 @AllowNull(false)
230 @Default(VideoPlaylistType.REGULAR)
231 @Column
232 type: VideoPlaylistType
233
234 @ForeignKey(() => AccountModel)
235 @Column
236 ownerAccountId: number
237
238 @BelongsTo(() => AccountModel, {
239 foreignKey: {
240 allowNull: false
241 },
242 onDelete: 'CASCADE'
243 })
244 OwnerAccount: AccountModel
245
246 @ForeignKey(() => VideoChannelModel)
247 @Column
248 videoChannelId: number
249
250 @BelongsTo(() => VideoChannelModel, {
251 foreignKey: {
252 allowNull: true
253 },
254 onDelete: 'CASCADE'
255 })
256 VideoChannel: VideoChannelModel
257
258 @HasMany(() => VideoPlaylistElementModel, {
259 foreignKey: {
260 name: 'videoPlaylistId',
261 allowNull: false
262 },
263 onDelete: 'CASCADE'
264 })
265 VideoPlaylistElements: VideoPlaylistElementModel[]
266
267 @HasOne(() => ThumbnailModel, {
268
269 foreignKey: {
270 name: 'videoPlaylistId',
271 allowNull: true
272 },
273 onDelete: 'CASCADE',
274 hooks: true
275 })
276 Thumbnail: ThumbnailModel
277
278 static listForApi (options: {
279 followerActorId: number
280 start: number,
281 count: number,
282 sort: string,
283 type?: VideoPlaylistType,
284 accountId?: number,
285 videoChannelId?: number,
286 privateAndUnlisted?: boolean
287 }) {
288 const query = {
289 offset: options.start,
290 limit: options.count,
291 order: getSort(options.sort)
292 }
293
294 const scopes: (string | ScopeOptions)[] = [
295 {
296 method: [
297 ScopeNames.AVAILABLE_FOR_LIST,
298 {
299 type: options.type,
300 followerActorId: options.followerActorId,
301 accountId: options.accountId,
302 videoChannelId: options.videoChannelId,
303 privateAndUnlisted: options.privateAndUnlisted
304 } as AvailableForListOptions
305 ]
306 },
307 ScopeNames.WITH_VIDEOS_LENGTH,
308 ScopeNames.WITH_THUMBNAIL
309 ]
310
311 return VideoPlaylistModel
312 .scope(scopes)
313 .findAndCountAll(query)
314 .then(({ rows, count }) => {
315 return { total: count, data: rows }
316 })
317 }
318
319 static listPublicUrlsOfForAP (accountId: number, start: number, count: number) {
320 const query = {
321 attributes: [ 'url' ],
322 offset: start,
323 limit: count,
324 where: {
325 ownerAccountId: accountId,
326 privacy: VideoPlaylistPrivacy.PUBLIC
327 }
328 }
329
330 return VideoPlaylistModel.findAndCountAll(query)
331 .then(({ rows, count }) => {
332 return { total: count, data: rows.map(p => p.url) }
333 })
334 }
335
336 static listPlaylistIdsOf (accountId: number, videoIds: number[]) {
337 const query = {
338 attributes: [ 'id' ],
339 where: {
340 ownerAccountId: accountId
341 },
342 include: [
343 {
344 attributes: [ 'videoId', 'startTimestamp', 'stopTimestamp' ],
345 model: VideoPlaylistElementModel.unscoped(),
346 where: {
347 videoId: {
348 [Op.in]: videoIds // FIXME: sequelize ANY seems broken
349 }
350 },
351 required: true
352 }
353 ]
354 }
355
356 return VideoPlaylistModel.findAll(query)
357 }
358
359 static doesPlaylistExist (url: string) {
360 const query = {
361 attributes: [],
362 where: {
363 url
364 }
365 }
366
367 return VideoPlaylistModel
368 .findOne(query)
369 .then(e => !!e)
370 }
371
372 static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction) {
373 const where = buildWhereIdOrUUID(id)
374
375 const query = {
376 where,
377 transaction
378 }
379
380 return VideoPlaylistModel
381 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
382 .findOne(query)
383 }
384
385 static loadWithAccountAndChannel (id: number | string, transaction: Transaction) {
386 const where = buildWhereIdOrUUID(id)
387
388 const query = {
389 where,
390 transaction
391 }
392
393 return VideoPlaylistModel
394 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
395 .findOne(query)
396 }
397
398 static loadByUrlAndPopulateAccount (url: string) {
399 const query = {
400 where: {
401 url
402 }
403 }
404
405 return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
406 }
407
408 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
409 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
410 }
411
412 static getTypeLabel (type: VideoPlaylistType) {
413 return VIDEO_PLAYLIST_TYPES[type] || 'Unknown'
414 }
415
416 static resetPlaylistsOfChannel (videoChannelId: number, transaction: Transaction) {
417 const query = {
418 where: {
419 videoChannelId
420 },
421 transaction
422 }
423
424 return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
425 }
426
427 async setAndSaveThumbnail (thumbnail: ThumbnailModel, t: Transaction) {
428 thumbnail.videoPlaylistId = this.id
429
430 this.Thumbnail = await thumbnail.save({ transaction: t })
431 }
432
433 hasThumbnail () {
434 return !!this.Thumbnail
435 }
436
437 generateThumbnailName () {
438 const extension = '.jpg'
439
440 return 'playlist-' + this.uuid + extension
441 }
442
443 getThumbnailUrl () {
444 if (!this.hasThumbnail()) return null
445
446 return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename
447 }
448
449 getThumbnailStaticPath () {
450 if (!this.hasThumbnail()) return null
451
452 return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
453 }
454
455 setAsRefreshed () {
456 this.changed('updatedAt', true)
457
458 return this.save()
459 }
460
461 isOwned () {
462 return this.OwnerAccount.isOwned()
463 }
464
465 isOutdated () {
466 if (this.isOwned()) return false
467
468 return isOutdated(this, ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL)
469 }
470
471 toFormattedJSON (): VideoPlaylist {
472 return {
473 id: this.id,
474 uuid: this.uuid,
475 isLocal: this.isOwned(),
476
477 displayName: this.name,
478 description: this.description,
479 privacy: {
480 id: this.privacy,
481 label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
482 },
483
484 thumbnailPath: this.getThumbnailStaticPath(),
485
486 type: {
487 id: this.type,
488 label: VideoPlaylistModel.getTypeLabel(this.type)
489 },
490
491 videosLength: this.get('videosLength') as number,
492
493 createdAt: this.createdAt,
494 updatedAt: this.updatedAt,
495
496 ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
497 videoChannel: this.VideoChannel ? this.VideoChannel.toFormattedSummaryJSON() : null
498 }
499 }
500
501 toActivityPubObject (page: number, t: Transaction): Promise<PlaylistObject> {
502 const handler = (start: number, count: number) => {
503 return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
504 }
505
506 let icon: ActivityIconObject
507 if (this.hasThumbnail()) {
508 icon = {
509 type: 'Image' as 'Image',
510 url: this.getThumbnailUrl(),
511 mediaType: 'image/jpeg' as 'image/jpeg',
512 width: THUMBNAILS_SIZE.width,
513 height: THUMBNAILS_SIZE.height
514 }
515 }
516
517 return activityPubCollectionPagination(this.url, handler, page)
518 .then(o => {
519 return Object.assign(o, {
520 type: 'Playlist' as 'Playlist',
521 name: this.name,
522 content: this.description,
523 uuid: this.uuid,
524 published: this.createdAt.toISOString(),
525 updated: this.updatedAt.toISOString(),
526 attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
527 icon
528 })
529 })
530 }
531}
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
index c87f71277..fda2d7cea 100644
--- a/server/models/video/video-share.ts
+++ b/server/models/video/video-share.ts
@@ -2,7 +2,7 @@ import * as Sequelize from 'sequelize'
2import * as Bluebird from 'bluebird' 2import * as Bluebird from 'bluebird'
3import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 3import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
4import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 4import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
5import { CONSTRAINTS_FIELDS } from '../../initializers' 5import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
6import { AccountModel } from '../account/account' 6import { AccountModel } from '../account/account'
7import { ActorModel } from '../activitypub/actor' 7import { ActorModel } from '../activitypub/actor'
8import { throwIfNotValid } from '../utils' 8import { throwIfNotValid } from '../utils'
@@ -14,15 +14,15 @@ enum ScopeNames {
14 WITH_ACTOR = 'WITH_ACTOR' 14 WITH_ACTOR = 'WITH_ACTOR'
15} 15}
16 16
17@Scopes({ 17@Scopes(() => ({
18 [ScopeNames.FULL]: { 18 [ScopeNames.FULL]: {
19 include: [ 19 include: [
20 { 20 {
21 model: () => ActorModel, 21 model: ActorModel,
22 required: true 22 required: true
23 }, 23 },
24 { 24 {
25 model: () => VideoModel, 25 model: VideoModel,
26 required: true 26 required: true
27 } 27 }
28 ] 28 ]
@@ -30,12 +30,12 @@ enum ScopeNames {
30 [ScopeNames.WITH_ACTOR]: { 30 [ScopeNames.WITH_ACTOR]: {
31 include: [ 31 include: [
32 { 32 {
33 model: () => ActorModel, 33 model: ActorModel,
34 required: true 34 required: true
35 } 35 }
36 ] 36 ]
37 } 37 }
38}) 38}))
39@Table({ 39@Table({
40 tableName: 'videoShare', 40 tableName: 'videoShare',
41 indexes: [ 41 indexes: [
@@ -125,7 +125,7 @@ export class VideoShareModel extends Model<VideoShareModel> {
125 .then(res => res.map(r => r.Actor)) 125 .then(res => res.map(r => r.Actor))
126 } 126 }
127 127
128 static loadActorsByVideoOwner (actorOwnerId: number, t: Sequelize.Transaction): Bluebird<ActorModel[]> { 128 static loadActorsWhoSharedVideosOf (actorOwnerId: number, t: Sequelize.Transaction): Bluebird<ActorModel[]> {
129 const query = { 129 const query = {
130 attributes: [], 130 attributes: [],
131 include: [ 131 include: [
@@ -200,4 +200,17 @@ export class VideoShareModel extends Model<VideoShareModel> {
200 200
201 return VideoShareModel.findAndCountAll(query) 201 return VideoShareModel.findAndCountAll(query)
202 } 202 }
203
204 static cleanOldSharesOf (videoId: number, beforeUpdatedAt: Date) {
205 const query = {
206 where: {
207 updatedAt: {
208 [Sequelize.Op.lt]: beforeUpdatedAt
209 },
210 videoId
211 }
212 }
213
214 return VideoShareModel.destroy(query)
215 }
203} 216}
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
new file mode 100644
index 000000000..31dc82c54
--- /dev/null
+++ b/server/models/video/video-streaming-playlist.ts
@@ -0,0 +1,172 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, HasMany, Is, Model, Table, UpdatedAt, DataType } from 'sequelize-typescript'
2import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
3import { throwIfNotValid } from '../utils'
4import { VideoModel } from './video'
5import { VideoRedundancyModel } from '../redundancy/video-redundancy'
6import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
7import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
8import { CONSTRAINTS_FIELDS, STATIC_PATHS, P2P_MEDIA_LOADER_PEER_VERSION } from '../../initializers/constants'
9import { VideoFileModel } from './video-file'
10import { join } from 'path'
11import { sha1 } from '../../helpers/core-utils'
12import { isArrayOf } from '../../helpers/custom-validators/misc'
13import { QueryTypes, Op } from 'sequelize'
14
15@Table({
16 tableName: 'videoStreamingPlaylist',
17 indexes: [
18 {
19 fields: [ 'videoId' ]
20 },
21 {
22 fields: [ 'videoId', 'type' ],
23 unique: true
24 },
25 {
26 fields: [ 'p2pMediaLoaderInfohashes' ],
27 using: 'gin'
28 }
29 ]
30})
31export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistModel> {
32 @CreatedAt
33 createdAt: Date
34
35 @UpdatedAt
36 updatedAt: Date
37
38 @AllowNull(false)
39 @Column
40 type: VideoStreamingPlaylistType
41
42 @AllowNull(false)
43 @Is('PlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'playlist url'))
44 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
45 playlistUrl: string
46
47 @AllowNull(false)
48 @Is('VideoStreamingPlaylistInfoHashes', value => throwIfNotValid(value, v => isArrayOf(v, isVideoFileInfoHashValid), 'info hashes'))
49 @Column(DataType.ARRAY(DataType.STRING))
50 p2pMediaLoaderInfohashes: string[]
51
52 @AllowNull(false)
53 @Column
54 p2pMediaLoaderPeerVersion: number
55
56 @AllowNull(false)
57 @Is('VideoStreamingSegmentsSha256Url', value => throwIfNotValid(value, isActivityPubUrlValid, 'segments sha256 url'))
58 @Column
59 segmentsSha256Url: string
60
61 @ForeignKey(() => VideoModel)
62 @Column
63 videoId: number
64
65 @BelongsTo(() => VideoModel, {
66 foreignKey: {
67 allowNull: false
68 },
69 onDelete: 'CASCADE'
70 })
71 Video: VideoModel
72
73 @HasMany(() => VideoRedundancyModel, {
74 foreignKey: {
75 allowNull: false
76 },
77 onDelete: 'CASCADE',
78 hooks: true
79 })
80 RedundancyVideos: VideoRedundancyModel[]
81
82 static doesInfohashExist (infoHash: string) {
83 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
84 const options = {
85 type: QueryTypes.SELECT as QueryTypes.SELECT,
86 bind: { infoHash },
87 raw: true
88 }
89
90 return VideoModel.sequelize.query<object>(query, options)
91 .then(results => results.length === 1)
92 }
93
94 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, videoFiles: VideoFileModel[]) {
95 const hashes: string[] = []
96
97 // https://github.com/Novage/p2p-media-loader/blob/master/p2p-media-loader-core/lib/p2p-media-manager.ts#L115
98 for (let i = 0; i < videoFiles.length; i++) {
99 hashes.push(sha1(`${P2P_MEDIA_LOADER_PEER_VERSION}${playlistUrl}+V${i}`))
100 }
101
102 return hashes
103 }
104
105 static listByIncorrectPeerVersion () {
106 const query = {
107 where: {
108 p2pMediaLoaderPeerVersion: {
109 [Op.ne]: P2P_MEDIA_LOADER_PEER_VERSION
110 }
111 }
112 }
113
114 return VideoStreamingPlaylistModel.findAll(query)
115 }
116
117 static loadWithVideo (id: number) {
118 const options = {
119 include: [
120 {
121 model: VideoModel.unscoped(),
122 required: true
123 }
124 ]
125 }
126
127 return VideoStreamingPlaylistModel.findByPk(id, options)
128 }
129
130 static getHlsPlaylistFilename (resolution: number) {
131 return resolution + '.m3u8'
132 }
133
134 static getMasterHlsPlaylistFilename () {
135 return 'master.m3u8'
136 }
137
138 static getHlsSha256SegmentsFilename () {
139 return 'segments-sha256.json'
140 }
141
142 static getHlsVideoName (uuid: string, resolution: number) {
143 return `${uuid}-${resolution}-fragmented.mp4`
144 }
145
146 static getHlsMasterPlaylistStaticPath (videoUUID: string) {
147 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename())
148 }
149
150 static getHlsPlaylistStaticPath (videoUUID: string, resolution: number) {
151 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
152 }
153
154 static getHlsSha256SegmentsStaticPath (videoUUID: string) {
155 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
156 }
157
158 getStringType () {
159 if (this.type === VideoStreamingPlaylistType.HLS) return 'hls'
160
161 return 'unknown'
162 }
163
164 getVideoRedundancyUrl (baseUrlHttp: string) {
165 return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getStringType() + '/' + this.Video.uuid
166 }
167
168 hasSameUniqueKeysThan (other: VideoStreamingPlaylistModel) {
169 return this.type === other.type &&
170 this.videoId === other.videoId
171 }
172}
diff --git a/server/models/video/video-views.ts b/server/models/video/video-views.ts
index fde5f7056..40db5effd 100644
--- a/server/models/video/video-views.ts
+++ b/server/models/video/video-views.ts
@@ -4,6 +4,7 @@ import * as Sequelize from 'sequelize'
4 4
5@Table({ 5@Table({
6 tableName: 'videoView', 6 tableName: 'videoView',
7 updatedAt: false,
7 indexes: [ 8 indexes: [
8 { 9 {
9 fields: [ 'videoId' ] 10 fields: [ 'videoId' ]
@@ -41,4 +42,18 @@ export class VideoViewModel extends Model<VideoViewModel> {
41 }) 42 })
42 Video: VideoModel 43 Video: VideoModel
43 44
45 static removeOldRemoteViewsHistory (beforeDate: string) {
46 const query = {
47 where: {
48 startDate: {
49 [Sequelize.Op.lt]: beforeDate
50 },
51 videoId: {
52 [Sequelize.Op.in]: Sequelize.literal('(SELECT "id" FROM "video" WHERE "remote" IS TRUE)')
53 }
54 }
55 }
56
57 return VideoViewModel.destroy(query)
58 }
44} 59}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 80a6c7832..c0a7892a4 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -3,7 +3,18 @@ import { maxBy } from 'lodash'
3import * as magnetUtil from 'magnet-uri' 3import * as magnetUtil from 'magnet-uri'
4import * as parseTorrent from 'parse-torrent' 4import * as parseTorrent from 'parse-torrent'
5import { join } from 'path' 5import { join } from 'path'
6import * as Sequelize from 'sequelize' 6import {
7 CountOptions,
8 FindOptions,
9 IncludeOptions,
10 ModelIndexesOptions,
11 Op,
12 QueryTypes,
13 ScopeOptions,
14 Sequelize,
15 Transaction,
16 WhereOptions
17} from 'sequelize'
7import { 18import {
8 AllowNull, 19 AllowNull,
9 BeforeDestroy, 20 BeforeDestroy,
@@ -16,8 +27,6 @@ import {
16 ForeignKey, 27 ForeignKey,
17 HasMany, 28 HasMany,
18 HasOne, 29 HasOne,
19 IFindOptions,
20 IIncludeOptions,
21 Is, 30 Is,
22 IsInt, 31 IsInt,
23 IsUUID, 32 IsUUID,
@@ -45,35 +54,43 @@ import {
45 isVideoStateValid, 54 isVideoStateValid,
46 isVideoSupportValid 55 isVideoSupportValid
47} from '../../helpers/custom-validators/videos' 56} from '../../helpers/custom-validators/videos'
48import { generateImageFromVideoFile, getVideoFileResolution } from '../../helpers/ffmpeg-utils' 57import { getVideoFileResolution } from '../../helpers/ffmpeg-utils'
49import { logger } from '../../helpers/logger' 58import { logger } from '../../helpers/logger'
50import { getServerActor } from '../../helpers/utils' 59import { getServerActor } from '../../helpers/utils'
51import { 60import {
52 ACTIVITY_PUB, 61 ACTIVITY_PUB,
53 API_VERSION, 62 API_VERSION,
54 CONFIG,
55 CONSTRAINTS_FIELDS, 63 CONSTRAINTS_FIELDS,
56 PREVIEWS_SIZE, 64 HLS_REDUNDANCY_DIRECTORY,
65 HLS_STREAMING_PLAYLIST_DIRECTORY,
57 REMOTE_SCHEME, 66 REMOTE_SCHEME,
58 STATIC_DOWNLOAD_PATHS, 67 STATIC_DOWNLOAD_PATHS,
59 STATIC_PATHS, 68 STATIC_PATHS,
60 THUMBNAILS_SIZE,
61 VIDEO_CATEGORIES, 69 VIDEO_CATEGORIES,
62 VIDEO_LANGUAGES, 70 VIDEO_LANGUAGES,
63 VIDEO_LICENCES, 71 VIDEO_LICENCES,
64 VIDEO_PRIVACIES, 72 VIDEO_PRIVACIES,
65 VIDEO_STATES 73 VIDEO_STATES,
66} from '../../initializers' 74 WEBSERVER
75} from '../../initializers/constants'
67import { sendDeleteVideo } from '../../lib/activitypub/send' 76import { sendDeleteVideo } from '../../lib/activitypub/send'
68import { AccountModel } from '../account/account' 77import { AccountModel } from '../account/account'
69import { AccountVideoRateModel } from '../account/account-video-rate' 78import { AccountVideoRateModel } from '../account/account-video-rate'
70import { ActorModel } from '../activitypub/actor' 79import { ActorModel } from '../activitypub/actor'
71import { AvatarModel } from '../avatar/avatar' 80import { AvatarModel } from '../avatar/avatar'
72import { ServerModel } from '../server/server' 81import { ServerModel } from '../server/server'
73import { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils' 82import {
83 buildBlockedAccountSQL,
84 buildTrigramSearchIndex,
85 buildWhereIdOrUUID,
86 createSimilarityAttribute,
87 getVideoSort,
88 isOutdated,
89 throwIfNotValid
90} from '../utils'
74import { TagModel } from './tag' 91import { TagModel } from './tag'
75import { VideoAbuseModel } from './video-abuse' 92import { VideoAbuseModel } from './video-abuse'
76import { VideoChannelModel } from './video-channel' 93import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
77import { VideoCommentModel } from './video-comment' 94import { VideoCommentModel } from './video-comment'
78import { VideoFileModel } from './video-file' 95import { VideoFileModel } from './video-file'
79import { VideoShareModel } from './video-share' 96import { VideoShareModel } from './video-share'
@@ -91,13 +108,17 @@ import {
91 videoModelToFormattedDetailsJSON, 108 videoModelToFormattedDetailsJSON,
92 videoModelToFormattedJSON 109 videoModelToFormattedJSON
93} from './video-format-utils' 110} from './video-format-utils'
94import * as validator from 'validator'
95import { UserVideoHistoryModel } from '../account/user-video-history' 111import { UserVideoHistoryModel } from '../account/user-video-history'
96import { UserModel } from '../account/user' 112import { UserModel } from '../account/user'
97import { VideoImportModel } from './video-import' 113import { VideoImportModel } from './video-import'
114import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
115import { VideoPlaylistElementModel } from './video-playlist-element'
116import { CONFIG } from '../../initializers/config'
117import { ThumbnailModel } from './thumbnail'
118import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
98 119
99// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 120// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
100const indexes: Sequelize.DefineIndexesOptions[] = [ 121const indexes: (ModelIndexesOptions & { where?: WhereOptions })[] = [
101 buildTrigramSearchIndex('video_name_trigram', 'name'), 122 buildTrigramSearchIndex('video_name_trigram', 'name'),
102 123
103 { fields: [ 'createdAt' ] }, 124 { fields: [ 'createdAt' ] },
@@ -106,10 +127,18 @@ const indexes: Sequelize.DefineIndexesOptions[] = [
106 { fields: [ 'views' ] }, 127 { fields: [ 'views' ] },
107 { fields: [ 'channelId' ] }, 128 { fields: [ 'channelId' ] },
108 { 129 {
130 fields: [ 'originallyPublishedAt' ],
131 where: {
132 originallyPublishedAt: {
133 [Op.ne]: null
134 }
135 }
136 },
137 {
109 fields: [ 'category' ], // We don't care videos with an unknown category 138 fields: [ 'category' ], // We don't care videos with an unknown category
110 where: { 139 where: {
111 category: { 140 category: {
112 [Sequelize.Op.ne]: null 141 [Op.ne]: null
113 } 142 }
114 } 143 }
115 }, 144 },
@@ -117,7 +146,7 @@ const indexes: Sequelize.DefineIndexesOptions[] = [
117 fields: [ 'licence' ], // We don't care videos with an unknown licence 146 fields: [ 'licence' ], // We don't care videos with an unknown licence
118 where: { 147 where: {
119 licence: { 148 licence: {
120 [Sequelize.Op.ne]: null 149 [Op.ne]: null
121 } 150 }
122 } 151 }
123 }, 152 },
@@ -125,7 +154,7 @@ const indexes: Sequelize.DefineIndexesOptions[] = [
125 fields: [ 'language' ], // We don't care videos with an unknown language 154 fields: [ 'language' ], // We don't care videos with an unknown language
126 where: { 155 where: {
127 language: { 156 language: {
128 [Sequelize.Op.ne]: null 157 [Op.ne]: null
129 } 158 }
130 } 159 }
131 }, 160 },
@@ -159,11 +188,17 @@ export enum ScopeNames {
159 WITH_FILES = 'WITH_FILES', 188 WITH_FILES = 'WITH_FILES',
160 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', 189 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
161 WITH_BLACKLISTED = 'WITH_BLACKLISTED', 190 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
162 WITH_USER_HISTORY = 'WITH_USER_HISTORY' 191 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
192 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
193 WITH_USER_ID = 'WITH_USER_ID',
194 WITH_THUMBNAILS = 'WITH_THUMBNAILS'
163} 195}
164 196
165type ForAPIOptions = { 197type ForAPIOptions = {
166 ids: number[] 198 ids: number[]
199
200 videoPlaylistId?: number
201
167 withFiles?: boolean 202 withFiles?: boolean
168} 203}
169 204
@@ -171,6 +206,9 @@ type AvailableForListIDsOptions = {
171 serverAccountId: number 206 serverAccountId: number
172 followerActorId: number 207 followerActorId: number
173 includeLocalVideos: boolean 208 includeLocalVideos: boolean
209
210 withoutId?: boolean
211
174 filter?: VideoFilter 212 filter?: VideoFilter
175 categoryOneOf?: number[] 213 categoryOneOf?: number[]
176 nsfw?: boolean 214 nsfw?: boolean
@@ -178,72 +216,38 @@ type AvailableForListIDsOptions = {
178 languageOneOf?: string[] 216 languageOneOf?: string[]
179 tagsOneOf?: string[] 217 tagsOneOf?: string[]
180 tagsAllOf?: string[] 218 tagsAllOf?: string[]
219
181 withFiles?: boolean 220 withFiles?: boolean
221
182 accountId?: number 222 accountId?: number
183 videoChannelId?: number 223 videoChannelId?: number
224
225 videoPlaylistId?: number
226
184 trendingDays?: number 227 trendingDays?: number
185 user?: UserModel, 228 user?: UserModel,
186 historyOfUser?: UserModel 229 historyOfUser?: UserModel
187} 230}
188 231
189@Scopes({ 232@Scopes(() => ({
190 [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => { 233 [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => {
191 const accountInclude = { 234 const query: FindOptions = {
192 attributes: [ 'id', 'name' ], 235 where: {
193 model: AccountModel.unscoped(), 236 id: {
194 required: true, 237 [ Op.in ]: options.ids // FIXME: sequelize ANY seems broken
195 include: [
196 {
197 attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
198 model: ActorModel.unscoped(),
199 required: true,
200 include: [
201 {
202 attributes: [ 'host' ],
203 model: ServerModel.unscoped(),
204 required: false
205 },
206 {
207 model: AvatarModel.unscoped(),
208 required: false
209 }
210 ]
211 } 238 }
212 ] 239 },
213 }
214
215 const videoChannelInclude = {
216 attributes: [ 'name', 'description', 'id' ],
217 model: VideoChannelModel.unscoped(),
218 required: true,
219 include: [ 240 include: [
220 { 241 {
221 attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ], 242 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, true ] }),
222 model: ActorModel.unscoped(), 243 required: true
223 required: true,
224 include: [
225 {
226 attributes: [ 'host' ],
227 model: ServerModel.unscoped(),
228 required: false
229 },
230 {
231 model: AvatarModel.unscoped(),
232 required: false
233 }
234 ]
235 }, 244 },
236 accountInclude 245 {
237 ] 246 attributes: [ 'type', 'filename' ],
238 } 247 model: ThumbnailModel,
239 248 required: false
240 const query: IFindOptions<VideoModel> = {
241 where: {
242 id: {
243 [ Sequelize.Op.any ]: options.ids
244 } 249 }
245 }, 250 ]
246 include: [ videoChannelInclude ]
247 } 251 }
248 252
249 if (options.withFiles === true) { 253 if (options.withFiles === true) {
@@ -253,24 +257,36 @@ type AvailableForListIDsOptions = {
253 }) 257 })
254 } 258 }
255 259
260 if (options.videoPlaylistId) {
261 query.include.push({
262 model: VideoPlaylistElementModel.unscoped(),
263 required: true,
264 where: {
265 videoPlaylistId: options.videoPlaylistId
266 }
267 })
268 }
269
256 return query 270 return query
257 }, 271 },
258 [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => { 272 [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
259 const query: IFindOptions<VideoModel> = { 273 const attributes = options.withoutId === true ? [] : [ 'id' ]
274
275 const query: FindOptions = {
260 raw: true, 276 raw: true,
261 attributes: [ 'id' ], 277 attributes,
262 where: { 278 where: {
263 id: { 279 id: {
264 [ Sequelize.Op.and ]: [ 280 [ Op.and ]: [
265 { 281 {
266 [ Sequelize.Op.notIn ]: Sequelize.literal( 282 [ Op.notIn ]: Sequelize.literal(
267 '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")' 283 '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
268 ) 284 )
269 } 285 }
270 ] 286 ]
271 }, 287 },
272 channelId: { 288 channelId: {
273 [ Sequelize.Op.notIn ]: Sequelize.literal( 289 [ Op.notIn ]: Sequelize.literal(
274 '(' + 290 '(' +
275 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' + 291 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
276 buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) + 292 buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
@@ -288,12 +304,12 @@ type AvailableForListIDsOptions = {
288 // Always list public videos 304 // Always list public videos
289 privacy: VideoPrivacy.PUBLIC, 305 privacy: VideoPrivacy.PUBLIC,
290 // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding 306 // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding
291 [ Sequelize.Op.or ]: [ 307 [ Op.or ]: [
292 { 308 {
293 state: VideoState.PUBLISHED 309 state: VideoState.PUBLISHED
294 }, 310 },
295 { 311 {
296 [ Sequelize.Op.and ]: { 312 [ Op.and ]: {
297 state: VideoState.TO_TRANSCODE, 313 state: VideoState.TO_TRANSCODE,
298 waitTranscoding: false 314 waitTranscoding: false
299 } 315 }
@@ -304,8 +320,21 @@ type AvailableForListIDsOptions = {
304 Object.assign(query.where, privacyWhere) 320 Object.assign(query.where, privacyWhere)
305 } 321 }
306 322
323 if (options.videoPlaylistId) {
324 query.include.push({
325 attributes: [],
326 model: VideoPlaylistElementModel.unscoped(),
327 required: true,
328 where: {
329 videoPlaylistId: options.videoPlaylistId
330 }
331 })
332
333 query.subQuery = false
334 }
335
307 if (options.filter || options.accountId || options.videoChannelId) { 336 if (options.filter || options.accountId || options.videoChannelId) {
308 const videoChannelInclude: IIncludeOptions = { 337 const videoChannelInclude: IncludeOptions = {
309 attributes: [], 338 attributes: [],
310 model: VideoChannelModel.unscoped(), 339 model: VideoChannelModel.unscoped(),
311 required: true 340 required: true
@@ -318,7 +347,7 @@ type AvailableForListIDsOptions = {
318 } 347 }
319 348
320 if (options.filter || options.accountId) { 349 if (options.filter || options.accountId) {
321 const accountInclude: IIncludeOptions = { 350 const accountInclude: IncludeOptions = {
322 attributes: [], 351 attributes: [],
323 model: AccountModel.unscoped(), 352 model: AccountModel.unscoped(),
324 required: true 353 required: true
@@ -358,8 +387,8 @@ type AvailableForListIDsOptions = {
358 387
359 // Force actorId to be a number to avoid SQL injections 388 // Force actorId to be a number to avoid SQL injections
360 const actorIdNumber = parseInt(options.followerActorId.toString(), 10) 389 const actorIdNumber = parseInt(options.followerActorId.toString(), 10)
361 query.where[ 'id' ][ Sequelize.Op.and ].push({ 390 query.where[ 'id' ][ Op.and ].push({
362 [ Sequelize.Op.in ]: Sequelize.literal( 391 [ Op.in ]: Sequelize.literal(
363 '(' + 392 '(' +
364 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' + 393 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
365 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + 394 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
@@ -378,8 +407,8 @@ type AvailableForListIDsOptions = {
378 } 407 }
379 408
380 if (options.withFiles === true) { 409 if (options.withFiles === true) {
381 query.where[ 'id' ][ Sequelize.Op.and ].push({ 410 query.where[ 'id' ][ Op.and ].push({
382 [ Sequelize.Op.in ]: Sequelize.literal( 411 [ Op.in ]: Sequelize.literal(
383 '(SELECT "videoId" FROM "videoFile")' 412 '(SELECT "videoId" FROM "videoFile")'
384 ) 413 )
385 }) 414 })
@@ -393,8 +422,8 @@ type AvailableForListIDsOptions = {
393 } 422 }
394 423
395 if (options.tagsOneOf) { 424 if (options.tagsOneOf) {
396 query.where[ 'id' ][ Sequelize.Op.and ].push({ 425 query.where[ 'id' ][ Op.and ].push({
397 [ Sequelize.Op.in ]: Sequelize.literal( 426 [ Op.in ]: Sequelize.literal(
398 '(' + 427 '(' +
399 'SELECT "videoId" FROM "videoTag" ' + 428 'SELECT "videoId" FROM "videoTag" ' +
400 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + 429 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
@@ -405,8 +434,8 @@ type AvailableForListIDsOptions = {
405 } 434 }
406 435
407 if (options.tagsAllOf) { 436 if (options.tagsAllOf) {
408 query.where[ 'id' ][ Sequelize.Op.and ].push({ 437 query.where[ 'id' ][ Op.and ].push({
409 [ Sequelize.Op.in ]: Sequelize.literal( 438 [ Op.in ]: Sequelize.literal(
410 '(' + 439 '(' +
411 'SELECT "videoId" FROM "videoTag" ' + 440 'SELECT "videoId" FROM "videoTag" ' +
412 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' + 441 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
@@ -424,19 +453,19 @@ type AvailableForListIDsOptions = {
424 453
425 if (options.categoryOneOf) { 454 if (options.categoryOneOf) {
426 query.where[ 'category' ] = { 455 query.where[ 'category' ] = {
427 [ Sequelize.Op.or ]: options.categoryOneOf 456 [ Op.or ]: options.categoryOneOf
428 } 457 }
429 } 458 }
430 459
431 if (options.licenceOneOf) { 460 if (options.licenceOneOf) {
432 query.where[ 'licence' ] = { 461 query.where[ 'licence' ] = {
433 [ Sequelize.Op.or ]: options.licenceOneOf 462 [ Op.or ]: options.licenceOneOf
434 } 463 }
435 } 464 }
436 465
437 if (options.languageOneOf) { 466 if (options.languageOneOf) {
438 query.where[ 'language' ] = { 467 query.where[ 'language' ] = {
439 [ Sequelize.Op.or ]: options.languageOneOf 468 [ Op.or ]: options.languageOneOf
440 } 469 }
441 } 470 }
442 471
@@ -463,36 +492,60 @@ type AvailableForListIDsOptions = {
463 492
464 return query 493 return query
465 }, 494 },
495 [ ScopeNames.WITH_THUMBNAILS ]: {
496 include: [
497 {
498 model: ThumbnailModel,
499 required: false
500 }
501 ]
502 },
503 [ ScopeNames.WITH_USER_ID ]: {
504 include: [
505 {
506 attributes: [ 'accountId' ],
507 model: VideoChannelModel.unscoped(),
508 required: true,
509 include: [
510 {
511 attributes: [ 'userId' ],
512 model: AccountModel.unscoped(),
513 required: true
514 }
515 ]
516 }
517 ]
518 },
466 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: { 519 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: {
467 include: [ 520 include: [
468 { 521 {
469 model: () => VideoChannelModel.unscoped(), 522 model: VideoChannelModel.unscoped(),
470 required: true, 523 required: true,
471 include: [ 524 include: [
472 { 525 {
473 attributes: { 526 attributes: {
474 exclude: [ 'privateKey', 'publicKey' ] 527 exclude: [ 'privateKey', 'publicKey' ]
475 }, 528 },
476 model: () => ActorModel.unscoped(), 529 model: ActorModel.unscoped(),
477 required: true, 530 required: true,
478 include: [ 531 include: [
479 { 532 {
480 attributes: [ 'host' ], 533 attributes: [ 'host' ],
481 model: () => ServerModel.unscoped(), 534 model: ServerModel.unscoped(),
482 required: false 535 required: false
483 }, 536 },
484 { 537 {
485 model: () => AvatarModel.unscoped(), 538 model: AvatarModel.unscoped(),
486 required: false 539 required: false
487 } 540 }
488 ] 541 ]
489 }, 542 },
490 { 543 {
491 model: () => AccountModel.unscoped(), 544 model: AccountModel.unscoped(),
492 required: true, 545 required: true,
493 include: [ 546 include: [
494 { 547 {
495 model: () => ActorModel.unscoped(), 548 model: ActorModel.unscoped(),
496 attributes: { 549 attributes: {
497 exclude: [ 'privateKey', 'publicKey' ] 550 exclude: [ 'privateKey', 'publicKey' ]
498 }, 551 },
@@ -500,11 +553,11 @@ type AvailableForListIDsOptions = {
500 include: [ 553 include: [
501 { 554 {
502 attributes: [ 'host' ], 555 attributes: [ 'host' ],
503 model: () => ServerModel.unscoped(), 556 model: ServerModel.unscoped(),
504 required: false 557 required: false
505 }, 558 },
506 { 559 {
507 model: () => AvatarModel.unscoped(), 560 model: AvatarModel.unscoped(),
508 required: false 561 required: false
509 } 562 }
510 ] 563 ]
@@ -516,38 +569,69 @@ type AvailableForListIDsOptions = {
516 ] 569 ]
517 }, 570 },
518 [ ScopeNames.WITH_TAGS ]: { 571 [ ScopeNames.WITH_TAGS ]: {
519 include: [ () => TagModel ] 572 include: [ TagModel ]
520 }, 573 },
521 [ ScopeNames.WITH_BLACKLISTED ]: { 574 [ ScopeNames.WITH_BLACKLISTED ]: {
522 include: [ 575 include: [
523 { 576 {
524 attributes: [ 'id', 'reason' ], 577 attributes: [ 'id', 'reason' ],
525 model: () => VideoBlacklistModel, 578 model: VideoBlacklistModel,
526 required: false 579 required: false
527 } 580 }
528 ] 581 ]
529 }, 582 },
530 [ ScopeNames.WITH_FILES ]: { 583 [ ScopeNames.WITH_FILES ]: (withRedundancies = false) => {
531 include: [ 584 let subInclude: any[] = []
532 { 585
533 model: () => VideoFileModel.unscoped(), 586 if (withRedundancies === true) {
534 // FIXME: typings 587 subInclude = [
535 [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join 588 {
536 required: false, 589 attributes: [ 'fileUrl' ],
537 include: [ 590 model: VideoRedundancyModel.unscoped(),
538 { 591 required: false
539 attributes: [ 'fileUrl' ], 592 }
540 model: () => VideoRedundancyModel.unscoped(), 593 ]
541 required: false 594 }
542 } 595
543 ] 596 return {
544 } 597 include: [
545 ] 598 {
599 model: VideoFileModel.unscoped(),
600 separate: true, // We may have multiple files, having multiple redundancies so let's separate this join
601 required: false,
602 include: subInclude
603 }
604 ]
605 }
606 },
607 [ ScopeNames.WITH_STREAMING_PLAYLISTS ]: (withRedundancies = false) => {
608 let subInclude: any[] = []
609
610 if (withRedundancies === true) {
611 subInclude = [
612 {
613 attributes: [ 'fileUrl' ],
614 model: VideoRedundancyModel.unscoped(),
615 required: false
616 }
617 ]
618 }
619
620 return {
621 include: [
622 {
623 model: VideoStreamingPlaylistModel.unscoped(),
624 separate: true, // We may have multiple streaming playlists, having multiple redundancies so let's separate this join
625 required: false,
626 include: subInclude
627 }
628 ]
629 }
546 }, 630 },
547 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: { 631 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: {
548 include: [ 632 include: [
549 { 633 {
550 model: () => ScheduleVideoUpdateModel.unscoped(), 634 model: ScheduleVideoUpdateModel.unscoped(),
551 required: false 635 required: false
552 } 636 }
553 ] 637 ]
@@ -566,7 +650,7 @@ type AvailableForListIDsOptions = {
566 ] 650 ]
567 } 651 }
568 } 652 }
569}) 653}))
570@Table({ 654@Table({
571 tableName: 'video', 655 tableName: 'video',
572 indexes 656 indexes
@@ -586,19 +670,19 @@ export class VideoModel extends Model<VideoModel> {
586 670
587 @AllowNull(true) 671 @AllowNull(true)
588 @Default(null) 672 @Default(null)
589 @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category')) 673 @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category', true))
590 @Column 674 @Column
591 category: number 675 category: number
592 676
593 @AllowNull(true) 677 @AllowNull(true)
594 @Default(null) 678 @Default(null)
595 @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence')) 679 @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence', true))
596 @Column 680 @Column
597 licence: number 681 licence: number
598 682
599 @AllowNull(true) 683 @AllowNull(true)
600 @Default(null) 684 @Default(null)
601 @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language')) 685 @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language', true))
602 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max)) 686 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
603 language: string 687 language: string
604 688
@@ -614,13 +698,13 @@ export class VideoModel extends Model<VideoModel> {
614 698
615 @AllowNull(true) 699 @AllowNull(true)
616 @Default(null) 700 @Default(null)
617 @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description')) 701 @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description', true))
618 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max)) 702 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
619 description: string 703 description: string
620 704
621 @AllowNull(true) 705 @AllowNull(true)
622 @Default(null) 706 @Default(null)
623 @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support')) 707 @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support', true))
624 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max)) 708 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
625 support: string 709 support: string
626 710
@@ -665,6 +749,10 @@ export class VideoModel extends Model<VideoModel> {
665 749
666 @AllowNull(false) 750 @AllowNull(false)
667 @Column 751 @Column
752 downloadEnabled: boolean
753
754 @AllowNull(false)
755 @Column
668 waitTranscoding: boolean 756 waitTranscoding: boolean
669 757
670 @AllowNull(false) 758 @AllowNull(false)
@@ -680,10 +768,15 @@ export class VideoModel extends Model<VideoModel> {
680 updatedAt: Date 768 updatedAt: Date
681 769
682 @AllowNull(false) 770 @AllowNull(false)
683 @Default(Sequelize.NOW) 771 @Default(DataType.NOW)
684 @Column 772 @Column
685 publishedAt: Date 773 publishedAt: Date
686 774
775 @AllowNull(true)
776 @Default(null)
777 @Column
778 originallyPublishedAt: Date
779
687 @ForeignKey(() => VideoChannelModel) 780 @ForeignKey(() => VideoChannelModel)
688 @Column 781 @Column
689 channelId: number 782 channelId: number
@@ -703,6 +796,25 @@ export class VideoModel extends Model<VideoModel> {
703 }) 796 })
704 Tags: TagModel[] 797 Tags: TagModel[]
705 798
799 @HasMany(() => ThumbnailModel, {
800 foreignKey: {
801 name: 'videoId',
802 allowNull: true
803 },
804 hooks: true,
805 onDelete: 'cascade'
806 })
807 Thumbnails: ThumbnailModel[]
808
809 @HasMany(() => VideoPlaylistElementModel, {
810 foreignKey: {
811 name: 'videoId',
812 allowNull: false
813 },
814 onDelete: 'cascade'
815 })
816 VideoPlaylistElements: VideoPlaylistElementModel[]
817
706 @HasMany(() => VideoAbuseModel, { 818 @HasMany(() => VideoAbuseModel, {
707 foreignKey: { 819 foreignKey: {
708 name: 'videoId', 820 name: 'videoId',
@@ -722,6 +834,16 @@ export class VideoModel extends Model<VideoModel> {
722 }) 834 })
723 VideoFiles: VideoFileModel[] 835 VideoFiles: VideoFileModel[]
724 836
837 @HasMany(() => VideoStreamingPlaylistModel, {
838 foreignKey: {
839 name: 'videoId',
840 allowNull: false
841 },
842 hooks: true,
843 onDelete: 'cascade'
844 })
845 VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
846
725 @HasMany(() => VideoShareModel, { 847 @HasMany(() => VideoShareModel, {
726 foreignKey: { 848 foreignKey: {
727 name: 'videoId', 849 name: 'videoId',
@@ -833,20 +955,19 @@ export class VideoModel extends Model<VideoModel> {
833 955
834 logger.info('Removing files of video %s.', instance.url) 956 logger.info('Removing files of video %s.', instance.url)
835 957
836 tasks.push(instance.removeThumbnail())
837
838 if (instance.isOwned()) { 958 if (instance.isOwned()) {
839 if (!Array.isArray(instance.VideoFiles)) { 959 if (!Array.isArray(instance.VideoFiles)) {
840 instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[] 960 instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[]
841 } 961 }
842 962
843 tasks.push(instance.removePreview())
844
845 // Remove physical files and torrents 963 // Remove physical files and torrents
846 instance.VideoFiles.forEach(file => { 964 instance.VideoFiles.forEach(file => {
847 tasks.push(instance.removeFile(file)) 965 tasks.push(instance.removeFile(file))
848 tasks.push(instance.removeTorrent(file)) 966 tasks.push(instance.removeTorrent(file))
849 }) 967 })
968
969 // Remove playlists file
970 tasks.push(instance.removeStreamingPlaylist())
850 } 971 }
851 972
852 // Do not wait video deletion because we could be in a transaction 973 // Do not wait video deletion because we could be in a transaction
@@ -858,10 +979,6 @@ export class VideoModel extends Model<VideoModel> {
858 return undefined 979 return undefined
859 } 980 }
860 981
861 static list () {
862 return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
863 }
864
865 static listLocal () { 982 static listLocal () {
866 const query = { 983 const query = {
867 where: { 984 where: {
@@ -869,7 +986,11 @@ export class VideoModel extends Model<VideoModel> {
869 } 986 }
870 } 987 }
871 988
872 return VideoModel.scope(ScopeNames.WITH_FILES).findAll(query) 989 return VideoModel.scope([
990 ScopeNames.WITH_FILES,
991 ScopeNames.WITH_STREAMING_PLAYLISTS,
992 ScopeNames.WITH_THUMBNAILS
993 ]).findAll(query)
873 } 994 }
874 995
875 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { 996 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
@@ -892,12 +1013,12 @@ export class VideoModel extends Model<VideoModel> {
892 distinct: true, 1013 distinct: true,
893 offset: start, 1014 offset: start,
894 limit: count, 1015 limit: count,
895 order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ]), 1016 order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ] as any), // FIXME: sequelize typings
896 where: { 1017 where: {
897 id: { 1018 id: {
898 [ Sequelize.Op.in ]: Sequelize.literal('(' + rawQuery + ')') 1019 [ Op.in ]: Sequelize.literal('(' + rawQuery + ')')
899 }, 1020 },
900 [ Sequelize.Op.or ]: [ 1021 [ Op.or ]: [
901 { privacy: VideoPrivacy.PUBLIC }, 1022 { privacy: VideoPrivacy.PUBLIC },
902 { privacy: VideoPrivacy.UNLISTED } 1023 { privacy: VideoPrivacy.UNLISTED }
903 ] 1024 ]
@@ -914,10 +1035,10 @@ export class VideoModel extends Model<VideoModel> {
914 required: false, 1035 required: false,
915 // We only want videos shared by this actor 1036 // We only want videos shared by this actor
916 where: { 1037 where: {
917 [ Sequelize.Op.and ]: [ 1038 [ Op.and ]: [
918 { 1039 {
919 id: { 1040 id: {
920 [ Sequelize.Op.not ]: null 1041 [ Op.not ]: null
921 } 1042 }
922 }, 1043 },
923 { 1044 {
@@ -961,9 +1082,8 @@ export class VideoModel extends Model<VideoModel> {
961 } 1082 }
962 1083
963 return Bluebird.all([ 1084 return Bluebird.all([
964 // FIXME: typing issue 1085 VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
965 VideoModel.findAll(query as any), 1086 VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
966 VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
967 ]).then(([ rows, totals ]) => { 1087 ]).then(([ rows, totals ]) => {
968 // totals: totalVideos + totalVideoShares 1088 // totals: totalVideos + totalVideoShares
969 let totalVideos = 0 1089 let totalVideos = 0
@@ -980,43 +1100,49 @@ export class VideoModel extends Model<VideoModel> {
980 } 1100 }
981 1101
982 static listUserVideosForApi (accountId: number, start: number, count: number, sort: string, withFiles = false) { 1102 static listUserVideosForApi (accountId: number, start: number, count: number, sort: string, withFiles = false) {
983 const query: IFindOptions<VideoModel> = { 1103 function buildBaseQuery (): FindOptions {
984 offset: start, 1104 return {
985 limit: count, 1105 offset: start,
986 order: getVideoSort(sort), 1106 limit: count,
987 include: [ 1107 order: getVideoSort(sort),
988 { 1108 include: [
989 model: VideoChannelModel, 1109 {
990 required: true, 1110 model: VideoChannelModel,
991 include: [ 1111 required: true,
992 { 1112 include: [
993 model: AccountModel, 1113 {
994 where: { 1114 model: AccountModel,
995 id: accountId 1115 where: {
996 }, 1116 id: accountId
997 required: true 1117 },
998 } 1118 required: true
999 ] 1119 }
1000 }, 1120 ]
1001 { 1121 }
1002 model: ScheduleVideoUpdateModel, 1122 ]
1003 required: false 1123 }
1004 },
1005 {
1006 model: VideoBlacklistModel,
1007 required: false
1008 }
1009 ]
1010 } 1124 }
1011 1125
1126 const countQuery = buildBaseQuery()
1127 const findQuery = buildBaseQuery()
1128
1129 const findScopes = [
1130 ScopeNames.WITH_SCHEDULED_UPDATE,
1131 ScopeNames.WITH_BLACKLISTED,
1132 ScopeNames.WITH_THUMBNAILS
1133 ]
1134
1012 if (withFiles === true) { 1135 if (withFiles === true) {
1013 query.include.push({ 1136 findQuery.include.push({
1014 model: VideoFileModel.unscoped(), 1137 model: VideoFileModel.unscoped(),
1015 required: true 1138 required: true
1016 }) 1139 })
1017 } 1140 }
1018 1141
1019 return VideoModel.findAndCountAll(query).then(({ rows, count }) => { 1142 return Promise.all([
1143 VideoModel.count(countQuery),
1144 VideoModel.scope(findScopes).findAll(findQuery)
1145 ]).then(([ count, rows ]) => {
1020 return { 1146 return {
1021 data: rows, 1147 data: rows,
1022 total: count 1148 total: count
@@ -1040,6 +1166,7 @@ export class VideoModel extends Model<VideoModel> {
1040 accountId?: number, 1166 accountId?: number,
1041 videoChannelId?: number, 1167 videoChannelId?: number,
1042 followerActorId?: number 1168 followerActorId?: number
1169 videoPlaylistId?: number,
1043 trendingDays?: number, 1170 trendingDays?: number,
1044 user?: UserModel, 1171 user?: UserModel,
1045 historyOfUser?: UserModel 1172 historyOfUser?: UserModel
@@ -1048,7 +1175,7 @@ export class VideoModel extends Model<VideoModel> {
1048 throw new Error('Try to filter all-local but no user has not the see all videos right') 1175 throw new Error('Try to filter all-local but no user has not the see all videos right')
1049 } 1176 }
1050 1177
1051 const query: IFindOptions<VideoModel> = { 1178 const query: FindOptions = {
1052 offset: options.start, 1179 offset: options.start,
1053 limit: options.count, 1180 limit: options.count,
1054 order: getVideoSort(options.sort) 1181 order: getVideoSort(options.sort)
@@ -1079,6 +1206,7 @@ export class VideoModel extends Model<VideoModel> {
1079 withFiles: options.withFiles, 1206 withFiles: options.withFiles,
1080 accountId: options.accountId, 1207 accountId: options.accountId,
1081 videoChannelId: options.videoChannelId, 1208 videoChannelId: options.videoChannelId,
1209 videoPlaylistId: options.videoPlaylistId,
1082 includeLocalVideos: options.includeLocalVideos, 1210 includeLocalVideos: options.includeLocalVideos,
1083 user: options.user, 1211 user: options.user,
1084 historyOfUser: options.historyOfUser, 1212 historyOfUser: options.historyOfUser,
@@ -1096,6 +1224,8 @@ export class VideoModel extends Model<VideoModel> {
1096 sort?: string 1224 sort?: string
1097 startDate?: string // ISO 8601 1225 startDate?: string // ISO 8601
1098 endDate?: string // ISO 8601 1226 endDate?: string // ISO 8601
1227 originallyPublishedStartDate?: string
1228 originallyPublishedEndDate?: string
1099 nsfw?: boolean 1229 nsfw?: boolean
1100 categoryOneOf?: number[] 1230 categoryOneOf?: number[]
1101 licenceOneOf?: number[] 1231 licenceOneOf?: number[]
@@ -1112,17 +1242,26 @@ export class VideoModel extends Model<VideoModel> {
1112 if (options.startDate || options.endDate) { 1242 if (options.startDate || options.endDate) {
1113 const publishedAtRange = {} 1243 const publishedAtRange = {}
1114 1244
1115 if (options.startDate) publishedAtRange[ Sequelize.Op.gte ] = options.startDate 1245 if (options.startDate) publishedAtRange[ Op.gte ] = options.startDate
1116 if (options.endDate) publishedAtRange[ Sequelize.Op.lte ] = options.endDate 1246 if (options.endDate) publishedAtRange[ Op.lte ] = options.endDate
1117 1247
1118 whereAnd.push({ publishedAt: publishedAtRange }) 1248 whereAnd.push({ publishedAt: publishedAtRange })
1119 } 1249 }
1120 1250
1251 if (options.originallyPublishedStartDate || options.originallyPublishedEndDate) {
1252 const originallyPublishedAtRange = {}
1253
1254 if (options.originallyPublishedStartDate) originallyPublishedAtRange[ Op.gte ] = options.originallyPublishedStartDate
1255 if (options.originallyPublishedEndDate) originallyPublishedAtRange[ Op.lte ] = options.originallyPublishedEndDate
1256
1257 whereAnd.push({ originallyPublishedAt: originallyPublishedAtRange })
1258 }
1259
1121 if (options.durationMin || options.durationMax) { 1260 if (options.durationMin || options.durationMax) {
1122 const durationRange = {} 1261 const durationRange = {}
1123 1262
1124 if (options.durationMin) durationRange[ Sequelize.Op.gte ] = options.durationMin 1263 if (options.durationMin) durationRange[ Op.gte ] = options.durationMin
1125 if (options.durationMax) durationRange[ Sequelize.Op.lte ] = options.durationMax 1264 if (options.durationMax) durationRange[ Op.lte ] = options.durationMax
1126 1265
1127 whereAnd.push({ duration: durationRange }) 1266 whereAnd.push({ duration: durationRange })
1128 } 1267 }
@@ -1134,7 +1273,7 @@ export class VideoModel extends Model<VideoModel> {
1134 whereAnd.push( 1273 whereAnd.push(
1135 { 1274 {
1136 id: { 1275 id: {
1137 [ Sequelize.Op.in ]: Sequelize.literal( 1276 [ Op.in ]: Sequelize.literal(
1138 '(' + 1277 '(' +
1139 'SELECT "video"."id" FROM "video" ' + 1278 'SELECT "video"."id" FROM "video" ' +
1140 'WHERE ' + 1279 'WHERE ' +
@@ -1160,7 +1299,7 @@ export class VideoModel extends Model<VideoModel> {
1160 ) 1299 )
1161 } 1300 }
1162 1301
1163 const query: IFindOptions<VideoModel> = { 1302 const query: FindOptions = {
1164 attributes: { 1303 attributes: {
1165 include: attributesInclude 1304 include: attributesInclude
1166 }, 1305 },
@@ -1168,7 +1307,7 @@ export class VideoModel extends Model<VideoModel> {
1168 limit: options.count, 1307 limit: options.count,
1169 order: getVideoSort(options.sort), 1308 order: getVideoSort(options.sort),
1170 where: { 1309 where: {
1171 [ Sequelize.Op.and ]: whereAnd 1310 [ Op.and ]: whereAnd
1172 } 1311 }
1173 } 1312 }
1174 1313
@@ -1190,18 +1329,32 @@ export class VideoModel extends Model<VideoModel> {
1190 return VideoModel.getAvailableForApi(query, queryOptions) 1329 return VideoModel.getAvailableForApi(query, queryOptions)
1191 } 1330 }
1192 1331
1193 static load (id: number | string, t?: Sequelize.Transaction) { 1332 static load (id: number | string, t?: Transaction) {
1194 const where = VideoModel.buildWhereIdOrUUID(id) 1333 const where = buildWhereIdOrUUID(id)
1195 const options = { 1334 const options = {
1196 where, 1335 where,
1197 transaction: t 1336 transaction: t
1198 } 1337 }
1199 1338
1200 return VideoModel.findOne(options) 1339 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1201 } 1340 }
1202 1341
1203 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { 1342 static loadWithRights (id: number | string, t?: Transaction) {
1204 const where = VideoModel.buildWhereIdOrUUID(id) 1343 const where = buildWhereIdOrUUID(id)
1344 const options = {
1345 where,
1346 transaction: t
1347 }
1348
1349 return VideoModel.scope([
1350 ScopeNames.WITH_BLACKLISTED,
1351 ScopeNames.WITH_USER_ID,
1352 ScopeNames.WITH_THUMBNAILS
1353 ]).findOne(options)
1354 }
1355
1356 static loadOnlyId (id: number | string, t?: Transaction) {
1357 const where = buildWhereIdOrUUID(id)
1205 1358
1206 const options = { 1359 const options = {
1207 attributes: [ 'id' ], 1360 attributes: [ 'id' ],
@@ -1209,12 +1362,15 @@ export class VideoModel extends Model<VideoModel> {
1209 transaction: t 1362 transaction: t
1210 } 1363 }
1211 1364
1212 return VideoModel.findOne(options) 1365 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1213 } 1366 }
1214 1367
1215 static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) { 1368 static loadWithFiles (id: number, t?: Transaction, logging?: boolean) {
1216 return VideoModel.scope(ScopeNames.WITH_FILES) 1369 return VideoModel.scope([
1217 .findById(id, { transaction: t, logging }) 1370 ScopeNames.WITH_FILES,
1371 ScopeNames.WITH_STREAMING_PLAYLISTS,
1372 ScopeNames.WITH_THUMBNAILS
1373 ]).findByPk(id, { transaction: t, logging })
1218 } 1374 }
1219 1375
1220 static loadByUUIDWithFile (uuid: string) { 1376 static loadByUUIDWithFile (uuid: string) {
@@ -1224,52 +1380,85 @@ export class VideoModel extends Model<VideoModel> {
1224 } 1380 }
1225 } 1381 }
1226 1382
1227 return VideoModel 1383 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1228 .scope([ ScopeNames.WITH_FILES ])
1229 .findOne(options)
1230 } 1384 }
1231 1385
1232 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 1386 static loadByUrl (url: string, transaction?: Transaction) {
1233 const query: IFindOptions<VideoModel> = { 1387 const query: FindOptions = {
1234 where: { 1388 where: {
1235 url 1389 url
1236 }, 1390 },
1237 transaction 1391 transaction
1238 } 1392 }
1239 1393
1240 return VideoModel.findOne(query) 1394 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
1241 } 1395 }
1242 1396
1243 static loadByUrlAndPopulateAccount (url: string, transaction?: Sequelize.Transaction) { 1397 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction) {
1244 const query: IFindOptions<VideoModel> = { 1398 const query: FindOptions = {
1245 where: { 1399 where: {
1246 url 1400 url
1247 }, 1401 },
1248 transaction 1402 transaction
1249 } 1403 }
1250 1404
1251 return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) 1405 return VideoModel.scope([
1406 ScopeNames.WITH_ACCOUNT_DETAILS,
1407 ScopeNames.WITH_FILES,
1408 ScopeNames.WITH_STREAMING_PLAYLISTS,
1409 ScopeNames.WITH_THUMBNAILS
1410 ]).findOne(query)
1252 } 1411 }
1253 1412
1254 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { 1413 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number) {
1255 const where = VideoModel.buildWhereIdOrUUID(id) 1414 const where = buildWhereIdOrUUID(id)
1256 1415
1257 const options = { 1416 const options = {
1258 order: [ [ 'Tags', 'name', 'ASC' ] ], 1417 order: [ [ 'Tags', 'name', 'ASC' ] ] as any,
1259 where, 1418 where,
1260 transaction: t 1419 transaction: t
1261 } 1420 }
1262 1421
1263 const scopes = [ 1422 const scopes: (string | ScopeOptions)[] = [
1264 ScopeNames.WITH_TAGS, 1423 ScopeNames.WITH_TAGS,
1265 ScopeNames.WITH_BLACKLISTED, 1424 ScopeNames.WITH_BLACKLISTED,
1425 ScopeNames.WITH_ACCOUNT_DETAILS,
1426 ScopeNames.WITH_SCHEDULED_UPDATE,
1266 ScopeNames.WITH_FILES, 1427 ScopeNames.WITH_FILES,
1428 ScopeNames.WITH_STREAMING_PLAYLISTS,
1429 ScopeNames.WITH_THUMBNAILS
1430 ]
1431
1432 if (userId) {
1433 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1434 }
1435
1436 return VideoModel
1437 .scope(scopes)
1438 .findOne(options)
1439 }
1440
1441 static loadForGetAPI (id: number | string, t?: Transaction, userId?: number) {
1442 const where = buildWhereIdOrUUID(id)
1443
1444 const options = {
1445 order: [ [ 'Tags', 'name', 'ASC' ] ] as any, // FIXME: sequelize typings
1446 where,
1447 transaction: t
1448 }
1449
1450 const scopes: (string | ScopeOptions)[] = [
1451 ScopeNames.WITH_TAGS,
1452 ScopeNames.WITH_BLACKLISTED,
1267 ScopeNames.WITH_ACCOUNT_DETAILS, 1453 ScopeNames.WITH_ACCOUNT_DETAILS,
1268 ScopeNames.WITH_SCHEDULED_UPDATE 1454 ScopeNames.WITH_SCHEDULED_UPDATE,
1455 ScopeNames.WITH_THUMBNAILS,
1456 { method: [ ScopeNames.WITH_FILES, true ] },
1457 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
1269 ] 1458 ]
1270 1459
1271 if (userId) { 1460 if (userId) {
1272 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings 1461 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1273 } 1462 }
1274 1463
1275 return VideoModel 1464 return VideoModel
@@ -1317,7 +1506,7 @@ export class VideoModel extends Model<VideoModel> {
1317 'LIMIT 1' 1506 'LIMIT 1'
1318 1507
1319 const options = { 1508 const options = {
1320 type: Sequelize.QueryTypes.SELECT, 1509 type: QueryTypes.SELECT,
1321 bind: { followerActorId, videoId }, 1510 bind: { followerActorId, videoId },
1322 raw: true 1511 raw: true
1323 } 1512 }
@@ -1334,17 +1523,18 @@ export class VideoModel extends Model<VideoModel> {
1334 const scopeOptions: AvailableForListIDsOptions = { 1523 const scopeOptions: AvailableForListIDsOptions = {
1335 serverAccountId: serverActor.Account.id, 1524 serverAccountId: serverActor.Account.id,
1336 followerActorId, 1525 followerActorId,
1337 includeLocalVideos: true 1526 includeLocalVideos: true,
1527 withoutId: true // Don't break aggregation
1338 } 1528 }
1339 1529
1340 const query: IFindOptions<VideoModel> = { 1530 const query: FindOptions = {
1341 attributes: [ field ], 1531 attributes: [ field ],
1342 limit: count, 1532 limit: count,
1343 group: field, 1533 group: field,
1344 having: Sequelize.where(Sequelize.fn('COUNT', Sequelize.col(field)), { 1534 having: Sequelize.where(
1345 [ Sequelize.Op.gte ]: threshold 1535 Sequelize.fn('COUNT', Sequelize.col(field)), { [ Op.gte ]: threshold }
1346 }) as any, // FIXME: typings 1536 ),
1347 order: [ this.sequelize.random() ] 1537 order: [ (this.sequelize as any).random() ]
1348 } 1538 }
1349 1539
1350 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] }) 1540 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] })
@@ -1360,7 +1550,7 @@ export class VideoModel extends Model<VideoModel> {
1360 required: false, 1550 required: false,
1361 where: { 1551 where: {
1362 startDate: { 1552 startDate: {
1363 [ Sequelize.Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays) 1553 [ Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1364 } 1554 }
1365 } 1555 }
1366 } 1556 }
@@ -1377,11 +1567,11 @@ export class VideoModel extends Model<VideoModel> {
1377 } 1567 }
1378 1568
1379 private static async getAvailableForApi ( 1569 private static async getAvailableForApi (
1380 query: IFindOptions<VideoModel>, 1570 query: FindOptions,
1381 options: AvailableForListIDsOptions, 1571 options: AvailableForListIDsOptions,
1382 countVideos = true 1572 countVideos = true
1383 ) { 1573 ) {
1384 const idsScope = { 1574 const idsScope: ScopeOptions = {
1385 method: [ 1575 method: [
1386 ScopeNames.AVAILABLE_FOR_LIST_IDS, options 1576 ScopeNames.AVAILABLE_FOR_LIST_IDS, options
1387 ] 1577 ]
@@ -1389,8 +1579,8 @@ export class VideoModel extends Model<VideoModel> {
1389 1579
1390 // Remove trending sort on count, because it uses a group by 1580 // Remove trending sort on count, because it uses a group by
1391 const countOptions = Object.assign({}, options, { trendingDays: undefined }) 1581 const countOptions = Object.assign({}, options, { trendingDays: undefined })
1392 const countQuery = Object.assign({}, query, { attributes: undefined, group: undefined }) 1582 const countQuery: CountOptions = Object.assign({}, query, { attributes: undefined, group: undefined })
1393 const countScope = { 1583 const countScope: ScopeOptions = {
1394 method: [ 1584 method: [
1395 ScopeNames.AVAILABLE_FOR_LIST_IDS, countOptions 1585 ScopeNames.AVAILABLE_FOR_LIST_IDS, countOptions
1396 ] 1586 ]
@@ -1404,18 +1594,7 @@ export class VideoModel extends Model<VideoModel> {
1404 1594
1405 if (ids.length === 0) return { data: [], total: count } 1595 if (ids.length === 0) return { data: [], total: count }
1406 1596
1407 // FIXME: typings 1597 const secondQuery: FindOptions = {
1408 const apiScope: any[] = [
1409 {
1410 method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ]
1411 }
1412 ]
1413
1414 if (options.user) {
1415 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
1416 }
1417
1418 const secondQuery = {
1419 offset: 0, 1598 offset: 0,
1420 limit: query.limit, 1599 limit: query.limit,
1421 attributes: query.attributes, 1600 attributes: query.attributes,
@@ -1425,6 +1604,23 @@ export class VideoModel extends Model<VideoModel> {
1425 ) 1604 )
1426 ] 1605 ]
1427 } 1606 }
1607
1608 const apiScope: (string | ScopeOptions)[] = []
1609
1610 if (options.user) {
1611 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
1612 }
1613
1614 apiScope.push({
1615 method: [
1616 ScopeNames.FOR_API, {
1617 ids,
1618 withFiles: options.withFiles,
1619 videoPlaylistId: options.videoPlaylistId
1620 } as ForAPIOptions
1621 ]
1622 })
1623
1428 const rows = await VideoModel.scope(apiScope).findAll(secondQuery) 1624 const rows = await VideoModel.scope(apiScope).findAll(secondQuery)
1429 1625
1430 return { 1626 return {
@@ -1453,10 +1649,6 @@ export class VideoModel extends Model<VideoModel> {
1453 return VIDEO_STATES[ id ] || 'Unknown' 1649 return VIDEO_STATES[ id ] || 'Unknown'
1454 } 1650 }
1455 1651
1456 static buildWhereIdOrUUID (id: number | string) {
1457 return validator.isInt('' + id) ? { id } : { uuid: id }
1458 }
1459
1460 getOriginalFile () { 1652 getOriginalFile () {
1461 if (Array.isArray(this.VideoFiles) === false) return undefined 1653 if (Array.isArray(this.VideoFiles) === false) return undefined
1462 1654
@@ -1464,19 +1656,41 @@ export class VideoModel extends Model<VideoModel> {
1464 return maxBy(this.VideoFiles, file => file.resolution) 1656 return maxBy(this.VideoFiles, file => file.resolution)
1465 } 1657 }
1466 1658
1659 async addAndSaveThumbnail (thumbnail: ThumbnailModel, transaction: Transaction) {
1660 thumbnail.videoId = this.id
1661
1662 const savedThumbnail = await thumbnail.save({ transaction })
1663
1664 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1665
1666 // Already have this thumbnail, skip
1667 if (this.Thumbnails.find(t => t.id === savedThumbnail.id)) return
1668
1669 this.Thumbnails.push(savedThumbnail)
1670 }
1671
1467 getVideoFilename (videoFile: VideoFileModel) { 1672 getVideoFilename (videoFile: VideoFileModel) {
1468 return this.uuid + '-' + videoFile.resolution + videoFile.extname 1673 return this.uuid + '-' + videoFile.resolution + videoFile.extname
1469 } 1674 }
1470 1675
1471 getThumbnailName () { 1676 generateThumbnailName () {
1472 // We always have a copy of the thumbnail 1677 return this.uuid + '.jpg'
1473 const extension = '.jpg'
1474 return this.uuid + extension
1475 } 1678 }
1476 1679
1477 getPreviewName () { 1680 getMiniature () {
1478 const extension = '.jpg' 1681 if (Array.isArray(this.Thumbnails) === false) return undefined
1479 return this.uuid + extension 1682
1683 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1684 }
1685
1686 generatePreviewName () {
1687 return this.uuid + '.jpg'
1688 }
1689
1690 getPreview () {
1691 if (Array.isArray(this.Thumbnails) === false) return undefined
1692
1693 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1480 } 1694 }
1481 1695
1482 getTorrentFileName (videoFile: VideoFileModel) { 1696 getTorrentFileName (videoFile: VideoFileModel) {
@@ -1488,24 +1702,6 @@ export class VideoModel extends Model<VideoModel> {
1488 return this.remote === false 1702 return this.remote === false
1489 } 1703 }
1490 1704
1491 createPreview (videoFile: VideoFileModel) {
1492 return generateImageFromVideoFile(
1493 this.getVideoFilePath(videoFile),
1494 CONFIG.STORAGE.PREVIEWS_DIR,
1495 this.getPreviewName(),
1496 PREVIEWS_SIZE
1497 )
1498 }
1499
1500 createThumbnail (videoFile: VideoFileModel) {
1501 return generateImageFromVideoFile(
1502 this.getVideoFilePath(videoFile),
1503 CONFIG.STORAGE.THUMBNAILS_DIR,
1504 this.getThumbnailName(),
1505 THUMBNAILS_SIZE
1506 )
1507 }
1508
1509 getTorrentFilePath (videoFile: VideoFileModel) { 1705 getTorrentFilePath (videoFile: VideoFileModel) {
1510 return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile)) 1706 return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1511 } 1707 }
@@ -1520,10 +1716,10 @@ export class VideoModel extends Model<VideoModel> {
1520 name: `${this.name} ${videoFile.resolution}p${videoFile.extname}`, 1716 name: `${this.name} ${videoFile.resolution}p${videoFile.extname}`,
1521 createdBy: 'PeerTube', 1717 createdBy: 'PeerTube',
1522 announceList: [ 1718 announceList: [
1523 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ], 1719 [ WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket' ],
1524 [ CONFIG.WEBSERVER.URL + '/tracker/announce' ] 1720 [ WEBSERVER.URL + '/tracker/announce' ]
1525 ], 1721 ],
1526 urlList: [ CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ] 1722 urlList: [ WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
1527 } 1723 }
1528 1724
1529 const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options) 1725 const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
@@ -1545,12 +1741,19 @@ export class VideoModel extends Model<VideoModel> {
1545 return '/videos/embed/' + this.uuid 1741 return '/videos/embed/' + this.uuid
1546 } 1742 }
1547 1743
1548 getThumbnailStaticPath () { 1744 getMiniatureStaticPath () {
1549 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) 1745 const thumbnail = this.getMiniature()
1746 if (!thumbnail) return null
1747
1748 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1550 } 1749 }
1551 1750
1552 getPreviewStaticPath () { 1751 getPreviewStaticPath () {
1553 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) 1752 const preview = this.getPreview()
1753 if (!preview) return null
1754
1755 // We use a local cache, so specify our cache endpoint instead of potential remote URL
1756 return join(STATIC_PATHS.PREVIEWS, preview.filename)
1554 } 1757 }
1555 1758
1556 toFormattedJSON (options?: VideoFormattingJSONOptions): Video { 1759 toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
@@ -1586,18 +1789,6 @@ export class VideoModel extends Model<VideoModel> {
1586 return `/api/${API_VERSION}/videos/${this.uuid}/description` 1789 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1587 } 1790 }
1588 1791
1589 removeThumbnail () {
1590 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
1591 return remove(thumbnailPath)
1592 .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
1593 }
1594
1595 removePreview () {
1596 const previewPath = join(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
1597 return remove(previewPath)
1598 .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err }))
1599 }
1600
1601 removeFile (videoFile: VideoFileModel, isRedundancy = false) { 1792 removeFile (videoFile: VideoFileModel, isRedundancy = false) {
1602 const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR 1793 const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR
1603 1794
@@ -1612,15 +1803,18 @@ export class VideoModel extends Model<VideoModel> {
1612 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) 1803 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1613 } 1804 }
1614 1805
1806 removeStreamingPlaylist (isRedundancy = false) {
1807 const baseDir = isRedundancy ? HLS_REDUNDANCY_DIRECTORY : HLS_STREAMING_PLAYLIST_DIRECTORY
1808
1809 const filePath = join(baseDir, this.uuid)
1810 return remove(filePath)
1811 .catch(err => logger.warn('Cannot delete playlist directory %s.', filePath, { err }))
1812 }
1813
1615 isOutdated () { 1814 isOutdated () {
1616 if (this.isOwned()) return false 1815 if (this.isOwned()) return false
1617 1816
1618 const now = Date.now() 1817 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1619 const createdAtTime = this.createdAt.getTime()
1620 const updatedAtTime = this.updatedAt.getTime()
1621
1622 return (now - createdAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL &&
1623 (now - updatedAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL
1624 } 1818 }
1625 1819
1626 setAsRefreshed () { 1820 setAsRefreshed () {
@@ -1634,8 +1828,8 @@ export class VideoModel extends Model<VideoModel> {
1634 let baseUrlWs 1828 let baseUrlWs
1635 1829
1636 if (this.isOwned()) { 1830 if (this.isOwned()) {
1637 baseUrlHttp = CONFIG.WEBSERVER.URL 1831 baseUrlHttp = WEBSERVER.URL
1638 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT 1832 baseUrlWs = WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT
1639 } else { 1833 } else {
1640 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host 1834 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
1641 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host 1835 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
@@ -1646,7 +1840,7 @@ export class VideoModel extends Model<VideoModel> {
1646 1840
1647 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) { 1841 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1648 const xs = this.getTorrentUrl(videoFile, baseUrlHttp) 1842 const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1649 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] 1843 const announce = this.getTrackerUrls(baseUrlHttp, baseUrlWs)
1650 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ] 1844 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1651 1845
1652 const redundancies = videoFile.RedundancyVideos 1846 const redundancies = videoFile.RedundancyVideos
@@ -1663,8 +1857,8 @@ export class VideoModel extends Model<VideoModel> {
1663 return magnetUtil.encode(magnetHash) 1857 return magnetUtil.encode(magnetHash)
1664 } 1858 }
1665 1859
1666 getThumbnailUrl (baseUrlHttp: string) { 1860 getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
1667 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() 1861 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1668 } 1862 }
1669 1863
1670 getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) { 1864 getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
@@ -1686,4 +1880,8 @@ export class VideoModel extends Model<VideoModel> {
1686 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) { 1880 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1687 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile) 1881 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
1688 } 1882 }
1883
1884 getBandwidthBits (videoFile: VideoFileModel) {
1885 return Math.ceil((videoFile.size * 8) / this.duration)
1886 }
1689} 1887}