aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models/video
diff options
context:
space:
mode:
Diffstat (limited to 'server/models/video')
-rw-r--r--server/models/video/index.ts9
-rw-r--r--server/models/video/tag-interface.ts20
-rw-r--r--server/models/video/tag.ts105
-rw-r--r--server/models/video/video-abuse-interface.ts41
-rw-r--r--server/models/video/video-abuse.ts210
-rw-r--r--server/models/video/video-blacklist-interface.ts39
-rw-r--r--server/models/video/video-blacklist.ts142
-rw-r--r--server/models/video/video-channel-interface.ts64
-rw-r--r--server/models/video/video-channel-share-interface.ts32
-rw-r--r--server/models/video/video-channel-share.ts120
-rw-r--r--server/models/video/video-channel.ts572
-rw-r--r--server/models/video/video-file-interface.ts24
-rw-r--r--server/models/video/video-file.ts109
-rw-r--r--server/models/video/video-interface.ts150
-rw-r--r--server/models/video/video-share-interface.ts30
-rw-r--r--server/models/video/video-share.ts118
-rw-r--r--server/models/video/video-tag-interface.ts18
-rw-r--r--server/models/video/video-tag.ts43
-rw-r--r--server/models/video/video.ts1845
19 files changed, 1514 insertions, 2177 deletions
diff --git a/server/models/video/index.ts b/server/models/video/index.ts
deleted file mode 100644
index e17bbfab4..000000000
--- a/server/models/video/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
1export * from './tag-interface'
2export * from './video-abuse-interface'
3export * from './video-blacklist-interface'
4export * from './video-channel-interface'
5export * from './video-tag-interface'
6export * from './video-file-interface'
7export * from './video-interface'
8export * from './video-share-interface'
9export * from './video-channel-share-interface'
diff --git a/server/models/video/tag-interface.ts b/server/models/video/tag-interface.ts
deleted file mode 100644
index 08e5c3246..000000000
--- a/server/models/video/tag-interface.ts
+++ /dev/null
@@ -1,20 +0,0 @@
1import * as Sequelize from 'sequelize'
2import * as Promise from 'bluebird'
3
4export namespace TagMethods {
5 export type FindOrCreateTags = (tags: string[], transaction: Sequelize.Transaction) => Promise<TagInstance[]>
6}
7
8export interface TagClass {
9 findOrCreateTags: TagMethods.FindOrCreateTags
10}
11
12export interface TagAttributes {
13 name: string
14}
15
16export interface TagInstance extends TagClass, TagAttributes, Sequelize.Instance<TagAttributes> {
17 id: number
18}
19
20export interface TagModel extends TagClass, Sequelize.Model<TagInstance, TagAttributes> {}
diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts
index 0c0757fc8..0ae74d808 100644
--- a/server/models/video/tag.ts
+++ b/server/models/video/tag.ts
@@ -1,73 +1,60 @@
1import * as Sequelize from 'sequelize' 1import * as Bluebird from 'bluebird'
2import * as Promise from 'bluebird' 2import { Transaction } from 'sequelize'
3 3import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
4import { addMethodsToModel } from '../utils' 4import { isVideoTagValid } from '../../helpers/custom-validators/videos'
5import { 5import { throwIfNotValid } from '../utils'
6 TagInstance, 6import { VideoModel } from './video'
7 TagAttributes, 7import { VideoTagModel } from './video-tag'
8 8
9 TagMethods 9@Table({
10} from './tag-interface' 10 tableName: 'tag',
11 11 timestamps: false,
12let Tag: Sequelize.Model<TagInstance, TagAttributes> 12 indexes: [
13let findOrCreateTags: TagMethods.FindOrCreateTags
14
15export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
16 Tag = sequelize.define<TagInstance, TagAttributes>('Tag',
17 { 13 {
18 name: { 14 fields: [ 'name' ],
19 type: DataTypes.STRING, 15 unique: true
20 allowNull: false
21 }
22 },
23 {
24 timestamps: false,
25 indexes: [
26 {
27 fields: [ 'name' ],
28 unique: true
29 }
30 ]
31 } 16 }
32 )
33
34 const classMethods = [
35 associate,
36
37 findOrCreateTags
38 ] 17 ]
39 addMethodsToModel(Tag, classMethods) 18})
19export class TagModel extends Model<TagModel> {
40 20
41 return Tag 21 @AllowNull(false)
42} 22 @Is('VideoTag', value => throwIfNotValid(value, isVideoTagValid, 'tag'))
23 @Column
24 name: string
43 25
44// --------------------------------------------------------------------------- 26 @CreatedAt
27 createdAt: Date
45 28
46function associate (models) { 29 @UpdatedAt
47 Tag.belongsToMany(models.Video, { 30 updatedAt: Date
31
32 @BelongsToMany(() => VideoModel, {
48 foreignKey: 'tagId', 33 foreignKey: 'tagId',
49 through: models.VideoTag, 34 through: () => VideoTagModel,
50 onDelete: 'CASCADE' 35 onDelete: 'CASCADE'
51 }) 36 })
52} 37 Videos: VideoModel[]
53 38
54findOrCreateTags = function (tags: string[], transaction: Sequelize.Transaction) { 39 static findOrCreateTags (tags: string[], transaction: Transaction) {
55 const tasks: Promise<TagInstance>[] = [] 40 const tasks: Bluebird<TagModel>[] = []
56 tags.forEach(tag => { 41 tags.forEach(tag => {
57 const query: Sequelize.FindOrInitializeOptions<TagAttributes> = { 42 const query = {
58 where: { 43 where: {
59 name: tag 44 name: tag
60 }, 45 },
61 defaults: { 46 defaults: {
62 name: tag 47 name: tag
48 }
63 } 49 }
64 }
65 50
66 if (transaction) query.transaction = transaction 51 if (transaction) query['transaction'] = transaction
67 52
68 const promise = Tag.findOrCreate(query).then(([ tagInstance ]) => tagInstance) 53 const promise = TagModel.findOrCreate(query)
69 tasks.push(promise) 54 .then(([ tagInstance ]) => tagInstance)
70 }) 55 tasks.push(promise)
56 })
71 57
72 return Promise.all(tasks) 58 return Promise.all(tasks)
59 }
73} 60}
diff --git a/server/models/video/video-abuse-interface.ts b/server/models/video/video-abuse-interface.ts
deleted file mode 100644
index feafc4a19..000000000
--- a/server/models/video/video-abuse-interface.ts
+++ /dev/null
@@ -1,41 +0,0 @@
1import * as Promise from 'bluebird'
2import * as Sequelize from 'sequelize'
3import { ResultList } from '../../../shared'
4import { VideoAbuse as FormattedVideoAbuse } from '../../../shared/models/videos/video-abuse.model'
5import { AccountInstance } from '../account/account-interface'
6import { ServerInstance } from '../server/server-interface'
7import { VideoInstance } from './video-interface'
8import { VideoAbuseObject } from '../../../shared/models/activitypub/objects/video-abuse-object'
9
10export namespace VideoAbuseMethods {
11 export type ToFormattedJSON = (this: VideoAbuseInstance) => FormattedVideoAbuse
12
13 export type ListForApi = (start: number, count: number, sort: string) => Promise< ResultList<VideoAbuseInstance> >
14 export type ToActivityPubObject = () => VideoAbuseObject
15}
16
17export interface VideoAbuseClass {
18 listForApi: VideoAbuseMethods.ListForApi
19 toActivityPubObject: VideoAbuseMethods.ToActivityPubObject
20}
21
22export interface VideoAbuseAttributes {
23 reason: string
24 videoId: number
25 reporterAccountId: number
26
27 Account?: AccountInstance
28 Video?: VideoInstance
29}
30
31export interface VideoAbuseInstance extends VideoAbuseClass, VideoAbuseAttributes, Sequelize.Instance<VideoAbuseAttributes> {
32 id: number
33 createdAt: Date
34 updatedAt: Date
35
36 Server: ServerInstance
37
38 toFormattedJSON: VideoAbuseMethods.ToFormattedJSON
39}
40
41export interface VideoAbuseModel extends VideoAbuseClass, Sequelize.Model<VideoAbuseInstance, VideoAbuseAttributes> {}
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts
index d09f5f7a1..d0ee969fb 100644
--- a/server/models/video/video-abuse.ts
+++ b/server/models/video/video-abuse.ts
@@ -1,142 +1,116 @@
1import * as Sequelize from 'sequelize' 1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2 2import { VideoAbuseObject } from '../../../shared/models/activitypub/objects'
3import { isVideoAbuseReasonValid } from '../../helpers/custom-validators/videos'
3import { CONFIG } from '../../initializers' 4import { CONFIG } from '../../initializers'
4import { isVideoAbuseReasonValid } from '../../helpers' 5import { AccountModel } from '../account/account'
5 6import { ServerModel } from '../server/server'
6import { addMethodsToModel, getSort } from '../utils' 7import { getSort, throwIfNotValid } from '../utils'
7import { 8import { VideoModel } from './video'
8 VideoAbuseInstance, 9
9 VideoAbuseAttributes, 10@Table({
10 11 tableName: 'videoAbuse',
11 VideoAbuseMethods 12 indexes: [
12} from './video-abuse-interface'
13import { VideoAbuseObject } from '../../../shared/models/activitypub/objects/video-abuse-object'
14
15let VideoAbuse: Sequelize.Model<VideoAbuseInstance, VideoAbuseAttributes>
16let toFormattedJSON: VideoAbuseMethods.ToFormattedJSON
17let listForApi: VideoAbuseMethods.ListForApi
18let toActivityPubObject: VideoAbuseMethods.ToActivityPubObject
19
20export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
21 VideoAbuse = sequelize.define<VideoAbuseInstance, VideoAbuseAttributes>('VideoAbuse',
22 { 13 {
23 reason: { 14 fields: [ 'videoId' ]
24 type: DataTypes.STRING,
25 allowNull: false,
26 validate: {
27 reasonValid: value => {
28 const res = isVideoAbuseReasonValid(value)
29 if (res === false) throw new Error('Video abuse reason is not valid.')
30 }
31 }
32 }
33 }, 15 },
34 { 16 {
35 indexes: [ 17 fields: [ 'reporterAccountId' ]
36 {
37 fields: [ 'videoId' ]
38 },
39 {
40 fields: [ 'reporterAccountId' ]
41 }
42 ]
43 } 18 }
44 )
45
46 const classMethods = [
47 associate,
48
49 listForApi
50 ]
51 const instanceMethods = [
52 toFormattedJSON,
53 toActivityPubObject
54 ] 19 ]
55 addMethodsToModel(VideoAbuse, classMethods, instanceMethods) 20})
56 21export class VideoAbuseModel extends Model<VideoAbuseModel> {
57 return VideoAbuse
58}
59
60// ------------------------------ METHODS ------------------------------
61
62toFormattedJSON = function (this: VideoAbuseInstance) {
63 let reporterServerHost
64
65 if (this.Account.Server) {
66 reporterServerHost = this.Account.Server.host
67 } else {
68 // It means it's our video
69 reporterServerHost = CONFIG.WEBSERVER.HOST
70 }
71
72 const json = {
73 id: this.id,
74 reason: this.reason,
75 reporterUsername: this.Account.name,
76 reporterServerHost,
77 videoId: this.Video.id,
78 videoUUID: this.Video.uuid,
79 videoName: this.Video.name,
80 createdAt: this.createdAt
81 }
82 22
83 return json 23 @AllowNull(false)
84} 24 @Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason'))
25 @Column
26 reason: string
85 27
86toActivityPubObject = function (this: VideoAbuseInstance) { 28 @CreatedAt
87 const videoAbuseObject: VideoAbuseObject = { 29 createdAt: Date
88 type: 'Flag' as 'Flag',
89 content: this.reason,
90 object: this.Video.url
91 }
92 30
93 return videoAbuseObject 31 @UpdatedAt
94} 32 updatedAt: Date
95 33
96// ------------------------------ STATICS ------------------------------ 34 @ForeignKey(() => AccountModel)
35 @Column
36 reporterAccountId: number
97 37
98function associate (models) { 38 @BelongsTo(() => AccountModel, {
99 VideoAbuse.belongsTo(models.Account, {
100 foreignKey: { 39 foreignKey: {
101 name: 'reporterAccountId',
102 allowNull: false 40 allowNull: false
103 }, 41 },
104 onDelete: 'CASCADE' 42 onDelete: 'cascade'
105 }) 43 })
44 Account: AccountModel
45
46 @ForeignKey(() => VideoModel)
47 @Column
48 videoId: number
106 49
107 VideoAbuse.belongsTo(models.Video, { 50 @BelongsTo(() => VideoModel, {
108 foreignKey: { 51 foreignKey: {
109 name: 'videoId',
110 allowNull: false 52 allowNull: false
111 }, 53 },
112 onDelete: 'CASCADE' 54 onDelete: 'cascade'
113 }) 55 })
114} 56 Video: VideoModel
57
58 static listForApi (start: number, count: number, sort: string) {
59 const query = {
60 offset: start,
61 limit: count,
62 order: [ getSort(sort) ],
63 include: [
64 {
65 model: AccountModel,
66 required: true,
67 include: [
68 {
69 model: ServerModel,
70 required: false
71 }
72 ]
73 },
74 {
75 model: VideoModel,
76 required: true
77 }
78 ]
79 }
115 80
116listForApi = function (start: number, count: number, sort: string) { 81 return VideoAbuseModel.findAndCountAll(query)
117 const query = { 82 .then(({ rows, count }) => {
118 offset: start, 83 return { total: count, data: rows }
119 limit: count, 84 })
120 order: [ getSort(sort) ],
121 include: [
122 {
123 model: VideoAbuse['sequelize'].models.Account,
124 required: true,
125 include: [
126 {
127 model: VideoAbuse['sequelize'].models.Server,
128 required: false
129 }
130 ]
131 },
132 {
133 model: VideoAbuse['sequelize'].models.Video,
134 required: true
135 }
136 ]
137 } 85 }
138 86
139 return VideoAbuse.findAndCountAll(query).then(({ rows, count }) => { 87 toFormattedJSON () {
140 return { total: count, data: rows } 88 let reporterServerHost
141 }) 89
90 if (this.Account.Server) {
91 reporterServerHost = this.Account.Server.host
92 } else {
93 // It means it's our video
94 reporterServerHost = CONFIG.WEBSERVER.HOST
95 }
96
97 return {
98 id: this.id,
99 reason: this.reason,
100 reporterUsername: this.Account.name,
101 reporterServerHost,
102 videoId: this.Video.id,
103 videoUUID: this.Video.uuid,
104 videoName: this.Video.name,
105 createdAt: this.createdAt
106 }
107 }
108
109 toActivityPubObject (): VideoAbuseObject {
110 return {
111 type: 'Flag' as 'Flag',
112 content: this.reason,
113 object: this.Video.url
114 }
115 }
142} 116}
diff --git a/server/models/video/video-blacklist-interface.ts b/server/models/video/video-blacklist-interface.ts
deleted file mode 100644
index be2483d4c..000000000
--- a/server/models/video/video-blacklist-interface.ts
+++ /dev/null
@@ -1,39 +0,0 @@
1import * as Sequelize from 'sequelize'
2import * as Promise from 'bluebird'
3
4import { SortType } from '../../helpers'
5import { ResultList } from '../../../shared'
6import { VideoInstance } from './video-interface'
7
8// Don't use barrel, import just what we need
9import { BlacklistedVideo as FormattedBlacklistedVideo } from '../../../shared/models/videos/video-blacklist.model'
10
11export namespace BlacklistedVideoMethods {
12 export type ToFormattedJSON = (this: BlacklistedVideoInstance) => FormattedBlacklistedVideo
13 export type ListForApi = (start: number, count: number, sort: SortType) => Promise< ResultList<BlacklistedVideoInstance> >
14 export type LoadByVideoId = (id: number) => Promise<BlacklistedVideoInstance>
15}
16
17export interface BlacklistedVideoClass {
18 toFormattedJSON: BlacklistedVideoMethods.ToFormattedJSON
19 listForApi: BlacklistedVideoMethods.ListForApi
20 loadByVideoId: BlacklistedVideoMethods.LoadByVideoId
21}
22
23export interface BlacklistedVideoAttributes {
24 videoId: number
25
26 Video?: VideoInstance
27}
28
29export interface BlacklistedVideoInstance
30 extends BlacklistedVideoClass, BlacklistedVideoAttributes, Sequelize.Instance<BlacklistedVideoAttributes> {
31 id: number
32 createdAt: Date
33 updatedAt: Date
34
35 toFormattedJSON: BlacklistedVideoMethods.ToFormattedJSON
36}
37
38export interface BlacklistedVideoModel
39 extends BlacklistedVideoClass, Sequelize.Model<BlacklistedVideoInstance, BlacklistedVideoAttributes> {}
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index ae8286285..6db562719 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -1,104 +1,80 @@
1import * as Sequelize from 'sequelize' 1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2
3import { SortType } from '../../helpers' 2import { SortType } from '../../helpers'
4import { addMethodsToModel, getSortOnModel } from '../utils' 3import { getSortOnModel } from '../utils'
5import { VideoInstance } from './video-interface' 4import { VideoModel } from './video'
6import {
7 BlacklistedVideoInstance,
8 BlacklistedVideoAttributes,
9
10 BlacklistedVideoMethods
11} from './video-blacklist-interface'
12
13let BlacklistedVideo: Sequelize.Model<BlacklistedVideoInstance, BlacklistedVideoAttributes>
14let toFormattedJSON: BlacklistedVideoMethods.ToFormattedJSON
15let listForApi: BlacklistedVideoMethods.ListForApi
16let loadByVideoId: BlacklistedVideoMethods.LoadByVideoId
17 5
18export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) { 6@Table({
19 BlacklistedVideo = sequelize.define<BlacklistedVideoInstance, BlacklistedVideoAttributes>('BlacklistedVideo', 7 tableName: 'videoBlacklist',
20 {}, 8 indexes: [
21 { 9 {
22 indexes: [ 10 fields: [ 'videoId' ],
23 { 11 unique: true
24 fields: [ 'videoId' ],
25 unique: true
26 }
27 ]
28 } 12 }
29 )
30
31 const classMethods = [
32 associate,
33
34 listForApi,
35 loadByVideoId
36 ] 13 ]
37 const instanceMethods = [ 14})
38 toFormattedJSON 15export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
39 ]
40 addMethodsToModel(BlacklistedVideo, classMethods, instanceMethods)
41
42 return BlacklistedVideo
43}
44 16
45// ------------------------------ METHODS ------------------------------ 17 @CreatedAt
18 createdAt: Date
46 19
47toFormattedJSON = function (this: BlacklistedVideoInstance) { 20 @UpdatedAt
48 let video: VideoInstance 21 updatedAt: Date
49
50 video = this.Video
51
52 return {
53 id: this.id,
54 videoId: this.videoId,
55 createdAt: this.createdAt,
56 updatedAt: this.updatedAt,
57 name: video.name,
58 uuid: video.uuid,
59 description: video.description,
60 duration: video.duration,
61 views: video.views,
62 likes: video.likes,
63 dislikes: video.dislikes,
64 nsfw: video.nsfw
65 }
66}
67 22
68// ------------------------------ STATICS ------------------------------ 23 @ForeignKey(() => VideoModel)
24 @Column
25 videoId: number
69 26
70function associate (models) { 27 @BelongsTo(() => VideoModel, {
71 BlacklistedVideo.belongsTo(models.Video, {
72 foreignKey: { 28 foreignKey: {
73 name: 'videoId',
74 allowNull: false 29 allowNull: false
75 }, 30 },
76 onDelete: 'CASCADE' 31 onDelete: 'cascade'
77 }) 32 })
78} 33 Video: VideoModel
34
35 static listForApi (start: number, count: number, sort: SortType) {
36 const query = {
37 offset: start,
38 limit: count,
39 order: [ getSortOnModel(sort.sortModel, sort.sortValue) ],
40 include: [ { model: VideoModel } ]
41 }
79 42
80listForApi = function (start: number, count: number, sort: SortType) { 43 return VideoBlacklistModel.findAndCountAll(query)
81 const query = { 44 .then(({ rows, count }) => {
82 offset: start, 45 return {
83 limit: count, 46 data: rows,
84 order: [ getSortOnModel(sort.sortModel, sort.sortValue) ], 47 total: count
85 include: [ { model: BlacklistedVideo['sequelize'].models.Video } ] 48 }
49 })
86 } 50 }
87 51
88 return BlacklistedVideo.findAndCountAll(query).then(({ rows, count }) => { 52 static loadByVideoId (id: number) {
89 return { 53 const query = {
90 data: rows, 54 where: {
91 total: count 55 videoId: id
56 }
92 } 57 }
93 })
94}
95 58
96loadByVideoId = function (id: number) { 59 return VideoBlacklistModel.findOne(query)
97 const query = {
98 where: {
99 videoId: id
100 }
101 } 60 }
102 61
103 return BlacklistedVideo.findOne(query) 62 toFormattedJSON () {
63 const video = this.Video
64
65 return {
66 id: this.id,
67 videoId: this.videoId,
68 createdAt: this.createdAt,
69 updatedAt: this.updatedAt,
70 name: video.name,
71 uuid: video.uuid,
72 description: video.description,
73 duration: video.duration,
74 views: video.views,
75 likes: video.likes,
76 dislikes: video.dislikes,
77 nsfw: video.nsfw
78 }
79 }
104} 80}
diff --git a/server/models/video/video-channel-interface.ts b/server/models/video/video-channel-interface.ts
deleted file mode 100644
index 21f81e901..000000000
--- a/server/models/video/video-channel-interface.ts
+++ /dev/null
@@ -1,64 +0,0 @@
1import * as Promise from 'bluebird'
2import * as Sequelize from 'sequelize'
3
4import { ResultList } from '../../../shared'
5import { VideoChannelObject } from '../../../shared/models/activitypub/objects/video-channel-object'
6import { VideoChannel as FormattedVideoChannel } from '../../../shared/models/videos/video-channel.model'
7import { AccountInstance } from '../account/account-interface'
8import { VideoInstance } from './video-interface'
9import { VideoChannelShareInstance } from './video-channel-share-interface'
10
11export namespace VideoChannelMethods {
12 export type ToFormattedJSON = (this: VideoChannelInstance) => FormattedVideoChannel
13 export type ToActivityPubObject = (this: VideoChannelInstance) => VideoChannelObject
14 export type IsOwned = (this: VideoChannelInstance) => boolean
15
16 export type CountByAccount = (accountId: number) => Promise<number>
17 export type ListForApi = (start: number, count: number, sort: string) => Promise< ResultList<VideoChannelInstance> >
18 export type LoadByIdAndAccount = (id: number, accountId: number) => Promise<VideoChannelInstance>
19 export type ListByAccount = (accountId: number) => Promise< ResultList<VideoChannelInstance> >
20 export type LoadAndPopulateAccount = (id: number) => Promise<VideoChannelInstance>
21 export type LoadByUUIDAndPopulateAccount = (uuid: string) => Promise<VideoChannelInstance>
22 export type LoadByUUID = (uuid: string, t?: Sequelize.Transaction) => Promise<VideoChannelInstance>
23 export type LoadByHostAndUUID = (uuid: string, serverHost: string, t?: Sequelize.Transaction) => Promise<VideoChannelInstance>
24 export type LoadAndPopulateAccountAndVideos = (id: number) => Promise<VideoChannelInstance>
25 export type LoadByUrl = (uuid: string, t?: Sequelize.Transaction) => Promise<VideoChannelInstance>
26 export type LoadByUUIDOrUrl = (uuid: string, url: string, t?: Sequelize.Transaction) => Promise<VideoChannelInstance>
27}
28
29export interface VideoChannelClass {
30 countByAccount: VideoChannelMethods.CountByAccount
31 listForApi: VideoChannelMethods.ListForApi
32 listByAccount: VideoChannelMethods.ListByAccount
33 loadByIdAndAccount: VideoChannelMethods.LoadByIdAndAccount
34 loadAndPopulateAccount: VideoChannelMethods.LoadAndPopulateAccount
35 loadByUUIDAndPopulateAccount: VideoChannelMethods.LoadByUUIDAndPopulateAccount
36 loadAndPopulateAccountAndVideos: VideoChannelMethods.LoadAndPopulateAccountAndVideos
37 loadByUrl: VideoChannelMethods.LoadByUrl
38 loadByUUIDOrUrl: VideoChannelMethods.LoadByUUIDOrUrl
39}
40
41export interface VideoChannelAttributes {
42 id?: number
43 uuid?: string
44 name: string
45 description: string
46 remote: boolean
47 url?: string
48
49 Account?: AccountInstance
50 Videos?: VideoInstance[]
51 VideoChannelShares?: VideoChannelShareInstance[]
52}
53
54export interface VideoChannelInstance extends VideoChannelClass, VideoChannelAttributes, Sequelize.Instance<VideoChannelAttributes> {
55 id: number
56 createdAt: Date
57 updatedAt: Date
58
59 isOwned: VideoChannelMethods.IsOwned
60 toFormattedJSON: VideoChannelMethods.ToFormattedJSON
61 toActivityPubObject: VideoChannelMethods.ToActivityPubObject
62}
63
64export interface VideoChannelModel extends VideoChannelClass, Sequelize.Model<VideoChannelInstance, VideoChannelAttributes> {}
diff --git a/server/models/video/video-channel-share-interface.ts b/server/models/video/video-channel-share-interface.ts
deleted file mode 100644
index 2fff41a1b..000000000
--- a/server/models/video/video-channel-share-interface.ts
+++ /dev/null
@@ -1,32 +0,0 @@
1import * as Bluebird from 'bluebird'
2import * as Sequelize from 'sequelize'
3import { AccountInstance } from '../account/account-interface'
4import { VideoChannelInstance } from './video-channel-interface'
5
6export namespace VideoChannelShareMethods {
7 export type LoadAccountsByShare = (videoChannelId: number, t: Sequelize.Transaction) => Bluebird<AccountInstance[]>
8 export type Load = (accountId: number, videoId: number, t: Sequelize.Transaction) => Bluebird<VideoChannelShareInstance>
9}
10
11export interface VideoChannelShareClass {
12 loadAccountsByShare: VideoChannelShareMethods.LoadAccountsByShare
13 load: VideoChannelShareMethods.Load
14}
15
16export interface VideoChannelShareAttributes {
17 accountId: number
18 videoChannelId: number
19}
20
21export interface VideoChannelShareInstance
22 extends VideoChannelShareClass, VideoChannelShareAttributes, Sequelize.Instance<VideoChannelShareAttributes> {
23 id: number
24 createdAt: Date
25 updatedAt: Date
26
27 Account?: AccountInstance
28 VideoChannel?: VideoChannelInstance
29}
30
31export interface VideoChannelShareModel
32 extends VideoChannelShareClass, Sequelize.Model<VideoChannelShareInstance, VideoChannelShareAttributes> {}
diff --git a/server/models/video/video-channel-share.ts b/server/models/video/video-channel-share.ts
index 2e9b658a3..cdba32fcd 100644
--- a/server/models/video/video-channel-share.ts
+++ b/server/models/video/video-channel-share.ts
@@ -1,85 +1,79 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { AccountModel } from '../account/account'
4import { VideoChannelModel } from './video-channel'
2 5
3import { addMethodsToModel } from '../utils' 6@Table({
4import { VideoChannelShareAttributes, VideoChannelShareInstance, VideoChannelShareMethods } from './video-channel-share-interface' 7 tableName: 'videoChannelShare',
5 8 indexes: [
6let VideoChannelShare: Sequelize.Model<VideoChannelShareInstance, VideoChannelShareAttributes>
7let loadAccountsByShare: VideoChannelShareMethods.LoadAccountsByShare
8let load: VideoChannelShareMethods.Load
9
10export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
11 VideoChannelShare = sequelize.define<VideoChannelShareInstance, VideoChannelShareAttributes>('VideoChannelShare',
12 { },
13 { 9 {
14 indexes: [ 10 fields: [ 'accountId' ]
15 { 11 },
16 fields: [ 'accountId' ] 12 {
17 }, 13 fields: [ 'videoChannelId' ]
18 {
19 fields: [ 'videoChannelId' ]
20 }
21 ]
22 } 14 }
23 )
24
25 const classMethods = [
26 associate,
27 load,
28 loadAccountsByShare
29 ] 15 ]
30 addMethodsToModel(VideoChannelShare, classMethods) 16})
17export class VideoChannelShareModel extends Model<VideoChannelShareModel> {
18 @CreatedAt
19 createdAt: Date
31 20
32 return VideoChannelShare 21 @UpdatedAt
33} 22 updatedAt: Date
34 23
35// ------------------------------ METHODS ------------------------------ 24 @ForeignKey(() => AccountModel)
25 @Column
26 accountId: number
36 27
37function associate (models) { 28 @BelongsTo(() => AccountModel, {
38 VideoChannelShare.belongsTo(models.Account, {
39 foreignKey: { 29 foreignKey: {
40 name: 'accountId',
41 allowNull: false 30 allowNull: false
42 }, 31 },
43 onDelete: 'cascade' 32 onDelete: 'cascade'
44 }) 33 })
34 Account: AccountModel
45 35
46 VideoChannelShare.belongsTo(models.VideoChannel, { 36 @ForeignKey(() => VideoChannelModel)
37 @Column
38 videoChannelId: number
39
40 @BelongsTo(() => VideoChannelModel, {
47 foreignKey: { 41 foreignKey: {
48 name: 'videoChannelId', 42 allowNull: false
49 allowNull: true
50 }, 43 },
51 onDelete: 'cascade' 44 onDelete: 'cascade'
52 }) 45 })
53} 46 VideoChannel: VideoChannelModel
54
55load = function (accountId: number, videoChannelId: number, t: Sequelize.Transaction) {
56 return VideoChannelShare.findOne({
57 where: {
58 accountId,
59 videoChannelId
60 },
61 include: [
62 VideoChannelShare['sequelize'].models.Account,
63 VideoChannelShare['sequelize'].models.VideoChannel
64 ],
65 transaction: t
66 })
67}
68 47
69loadAccountsByShare = function (videoChannelId: number, t: Sequelize.Transaction) { 48 static load (accountId: number, videoChannelId: number, t: Sequelize.Transaction) {
70 const query = { 49 return VideoChannelShareModel.findOne({
71 where: { 50 where: {
72 videoChannelId 51 accountId,
73 }, 52 videoChannelId
74 include: [ 53 },
75 { 54 include: [
76 model: VideoChannelShare['sequelize'].models.Account, 55 AccountModel,
77 required: true 56 VideoChannelModel
78 } 57 ],
79 ], 58 transaction: t
80 transaction: t 59 })
81 } 60 }
82 61
83 return VideoChannelShare.findAll(query) 62 static loadAccountsByShare (videoChannelId: number, t: Sequelize.Transaction) {
84 .then(res => res.map(r => r.Account)) 63 const query = {
64 where: {
65 videoChannelId
66 },
67 include: [
68 {
69 model: AccountModel,
70 required: true
71 }
72 ],
73 transaction: t
74 }
75
76 return VideoChannelShareModel.findAll(query)
77 .then(res => res.map(r => r.Account))
78 }
85} 79}
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 54f12dce3..9b545a4ef 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -1,371 +1,341 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { isVideoChannelDescriptionValid, isVideoChannelNameValid } from '../../helpers' 2import {
3import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 3 AfterDestroy,
4import { sendDeleteVideoChannel } from '../../lib/activitypub/send/send-delete' 4 AllowNull,
5 5 BelongsTo,
6import { addMethodsToModel, getSort } from '../utils' 6 Column,
7import { VideoChannelAttributes, VideoChannelInstance, VideoChannelMethods } from './video-channel-interface' 7 CreatedAt,
8import { getAnnounceActivityPubUrl } from '../../lib/activitypub/url' 8 DataType,
9import { activityPubCollection } from '../../helpers/activitypub' 9 Default,
10import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 10 ForeignKey,
11 11 HasMany,
12let VideoChannel: Sequelize.Model<VideoChannelInstance, VideoChannelAttributes> 12 Is,
13let toFormattedJSON: VideoChannelMethods.ToFormattedJSON 13 IsUUID,
14let toActivityPubObject: VideoChannelMethods.ToActivityPubObject 14 Model,
15let isOwned: VideoChannelMethods.IsOwned 15 Table,
16let countByAccount: VideoChannelMethods.CountByAccount 16 UpdatedAt
17let listForApi: VideoChannelMethods.ListForApi 17} from 'sequelize-typescript'
18let listByAccount: VideoChannelMethods.ListByAccount 18import { IFindOptions } from 'sequelize-typescript/lib/interfaces/IFindOptions'
19let loadByIdAndAccount: VideoChannelMethods.LoadByIdAndAccount 19import { activityPubCollection } from '../../helpers'
20let loadByUUID: VideoChannelMethods.LoadByUUID 20import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub'
21let loadAndPopulateAccount: VideoChannelMethods.LoadAndPopulateAccount 21import { isVideoChannelDescriptionValid, isVideoChannelNameValid } from '../../helpers/custom-validators/video-channels'
22let loadByUUIDAndPopulateAccount: VideoChannelMethods.LoadByUUIDAndPopulateAccount 22import { CONSTRAINTS_FIELDS } from '../../initializers'
23let loadByHostAndUUID: VideoChannelMethods.LoadByHostAndUUID 23import { getAnnounceActivityPubUrl } from '../../lib/activitypub'
24let loadAndPopulateAccountAndVideos: VideoChannelMethods.LoadAndPopulateAccountAndVideos 24import { sendDeleteVideoChannel } from '../../lib/activitypub/send'
25let loadByUrl: VideoChannelMethods.LoadByUrl 25import { AccountModel } from '../account/account'
26let loadByUUIDOrUrl: VideoChannelMethods.LoadByUUIDOrUrl 26import { ServerModel } from '../server/server'
27 27import { getSort, throwIfNotValid } from '../utils'
28export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) { 28import { VideoModel } from './video'
29 VideoChannel = sequelize.define<VideoChannelInstance, VideoChannelAttributes>('VideoChannel', 29import { VideoChannelShareModel } from './video-channel-share'
30
31@Table({
32 tableName: 'videoChannel',
33 indexes: [
30 { 34 {
31 uuid: { 35 fields: [ 'accountId' ]
32 type: DataTypes.UUID,
33 defaultValue: DataTypes.UUIDV4,
34 allowNull: false,
35 validate: {
36 isUUID: 4
37 }
38 },
39 name: {
40 type: DataTypes.STRING,
41 allowNull: false,
42 validate: {
43 nameValid: value => {
44 const res = isVideoChannelNameValid(value)
45 if (res === false) throw new Error('Video channel name is not valid.')
46 }
47 }
48 },
49 description: {
50 type: DataTypes.STRING,
51 allowNull: true,
52 validate: {
53 descriptionValid: value => {
54 const res = isVideoChannelDescriptionValid(value)
55 if (res === false) throw new Error('Video channel description is not valid.')
56 }
57 }
58 },
59 remote: {
60 type: DataTypes.BOOLEAN,
61 allowNull: false,
62 defaultValue: false
63 },
64 url: {
65 type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.URL.max),
66 allowNull: false,
67 validate: {
68 urlValid: value => {
69 const res = isActivityPubUrlValid(value)
70 if (res === false) throw new Error('Video channel URL is not valid.')
71 }
72 }
73 }
74 },
75 {
76 indexes: [
77 {
78 fields: [ 'accountId' ]
79 }
80 ],
81 hooks: {
82 afterDestroy
83 }
84 } 36 }
85 )
86
87 const classMethods = [
88 associate,
89
90 listForApi,
91 listByAccount,
92 loadByIdAndAccount,
93 loadAndPopulateAccount,
94 loadByUUIDAndPopulateAccount,
95 loadByUUID,
96 loadByHostAndUUID,
97 loadAndPopulateAccountAndVideos,
98 countByAccount,
99 loadByUrl,
100 loadByUUIDOrUrl
101 ] 37 ]
102 const instanceMethods = [ 38})
103 isOwned, 39export class VideoChannelModel extends Model<VideoChannelModel> {
104 toFormattedJSON,
105 toActivityPubObject
106 ]
107 addMethodsToModel(VideoChannel, classMethods, instanceMethods)
108 40
109 return VideoChannel 41 @AllowNull(false)
110} 42 @Default(DataType.UUIDV4)
43 @IsUUID(4)
44 @Column(DataType.UUID)
45 uuid: string
111 46
112// ------------------------------ METHODS ------------------------------ 47 @AllowNull(false)
48 @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelNameValid, 'name'))
49 @Column
50 name: string
113 51
114isOwned = function (this: VideoChannelInstance) { 52 @AllowNull(true)
115 return this.remote === false 53 @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description'))
116} 54 @Column
117 55 description: string
118toFormattedJSON = function (this: VideoChannelInstance) {
119 const json = {
120 id: this.id,
121 uuid: this.uuid,
122 name: this.name,
123 description: this.description,
124 isLocal: this.isOwned(),
125 createdAt: this.createdAt,
126 updatedAt: this.updatedAt
127 }
128 56
129 if (this.Account !== undefined) { 57 @AllowNull(false)
130 json['owner'] = { 58 @Column
131 name: this.Account.name, 59 remote: boolean
132 uuid: this.Account.uuid
133 }
134 }
135 60
136 if (Array.isArray(this.Videos)) { 61 @AllowNull(false)
137 json['videos'] = this.Videos.map(v => v.toFormattedJSON()) 62 @Is('VideoChannelUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
138 } 63 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.URL.max))
64 url: string
139 65
140 return json 66 @CreatedAt
141} 67 createdAt: Date
142 68
143toActivityPubObject = function (this: VideoChannelInstance) { 69 @UpdatedAt
144 let sharesObject 70 updatedAt: Date
145 if (Array.isArray(this.VideoChannelShares)) {
146 const shares: string[] = []
147 71
148 for (const videoChannelShare of this.VideoChannelShares) { 72 @ForeignKey(() => AccountModel)
149 const shareUrl = getAnnounceActivityPubUrl(this.url, videoChannelShare.Account) 73 @Column
150 shares.push(shareUrl) 74 accountId: number
151 }
152 75
153 sharesObject = activityPubCollection(shares) 76 @BelongsTo(() => AccountModel, {
154 } 77 foreignKey: {
155 78 allowNull: false
156 const json = { 79 },
157 type: 'VideoChannel' as 'VideoChannel', 80 onDelete: 'CASCADE'
158 id: this.url, 81 })
159 uuid: this.uuid, 82 Account: AccountModel
160 content: this.description,
161 name: this.name,
162 published: this.createdAt.toISOString(),
163 updated: this.updatedAt.toISOString(),
164 shares: sharesObject
165 }
166
167 return json
168}
169
170// ------------------------------ STATICS ------------------------------
171 83
172function associate (models) { 84 @HasMany(() => VideoModel, {
173 VideoChannel.belongsTo(models.Account, {
174 foreignKey: { 85 foreignKey: {
175 name: 'accountId', 86 name: 'channelId',
176 allowNull: false 87 allowNull: false
177 }, 88 },
178 onDelete: 'CASCADE' 89 onDelete: 'CASCADE'
179 }) 90 })
91 Videos: VideoModel[]
180 92
181 VideoChannel.hasMany(models.Video, { 93 @HasMany(() => VideoChannelShareModel, {
182 foreignKey: { 94 foreignKey: {
183 name: 'channelId', 95 name: 'channelId',
184 allowNull: false 96 allowNull: false
185 }, 97 },
186 onDelete: 'CASCADE' 98 onDelete: 'CASCADE'
187 }) 99 })
188} 100 VideoChannelShares: VideoChannelShareModel[]
189 101
190function afterDestroy (videoChannel: VideoChannelInstance) { 102 @AfterDestroy
191 if (videoChannel.isOwned()) { 103 static sendDeleteIfOwned (instance: VideoChannelModel) {
192 return sendDeleteVideoChannel(videoChannel, undefined) 104 if (instance.isOwned()) {
193 } 105 return sendDeleteVideoChannel(instance, undefined)
106 }
194 107
195 return undefined 108 return undefined
196} 109 }
197 110
198countByAccount = function (accountId: number) { 111 static countByAccount (accountId: number) {
199 const query = { 112 const query = {
200 where: { 113 where: {
201 accountId 114 accountId
115 }
202 } 116 }
117
118 return VideoChannelModel.count(query)
203 } 119 }
204 120
205 return VideoChannel.count(query) 121 static listForApi (start: number, count: number, sort: string) {
206} 122 const query = {
123 offset: start,
124 limit: count,
125 order: [ getSort(sort) ],
126 include: [
127 {
128 model: AccountModel,
129 required: true,
130 include: [ { model: ServerModel, required: false } ]
131 }
132 ]
133 }
207 134
208listForApi = function (start: number, count: number, sort: string) { 135 return VideoChannelModel.findAndCountAll(query)
209 const query = { 136 .then(({ rows, count }) => {
210 offset: start, 137 return { total: count, data: rows }
211 limit: count, 138 })
212 order: [ getSort(sort) ],
213 include: [
214 {
215 model: VideoChannel['sequelize'].models.Account,
216 required: true,
217 include: [ { model: VideoChannel['sequelize'].models.Server, required: false } ]
218 }
219 ]
220 } 139 }
221 140
222 return VideoChannel.findAndCountAll(query).then(({ rows, count }) => { 141 static listByAccount (accountId: number) {
223 return { total: count, data: rows } 142 const query = {
224 }) 143 order: [ getSort('createdAt') ],
225} 144 include: [
145 {
146 model: AccountModel,
147 where: {
148 id: accountId
149 },
150 required: true,
151 include: [ { model: ServerModel, required: false } ]
152 }
153 ]
154 }
226 155
227listByAccount = function (accountId: number) { 156 return VideoChannelModel.findAndCountAll(query)
228 const query = { 157 .then(({ rows, count }) => {
229 order: [ getSort('createdAt') ], 158 return { total: count, data: rows }
230 include: [ 159 })
231 {
232 model: VideoChannel['sequelize'].models.Account,
233 where: {
234 id: accountId
235 },
236 required: true,
237 include: [ { model: VideoChannel['sequelize'].models.Server, required: false } ]
238 }
239 ]
240 } 160 }
241 161
242 return VideoChannel.findAndCountAll(query).then(({ rows, count }) => { 162 static loadByUUID (uuid: string, t?: Sequelize.Transaction) {
243 return { total: count, data: rows } 163 const query: IFindOptions<VideoChannelModel> = {
244 }) 164 where: {
245} 165 uuid
166 }
167 }
168
169 if (t !== undefined) query.transaction = t
246 170
247loadByUUID = function (uuid: string, t?: Sequelize.Transaction) { 171 return VideoChannelModel.findOne(query)
248 const query: Sequelize.FindOptions<VideoChannelAttributes> = { 172 }
249 where: { 173
250 uuid 174 static loadByUrl (url: string, t?: Sequelize.Transaction) {
175 const query: IFindOptions<VideoChannelModel> = {
176 where: {
177 url
178 },
179 include: [ AccountModel ]
251 } 180 }
181
182 if (t !== undefined) query.transaction = t
183
184 return VideoChannelModel.findOne(query)
252 } 185 }
253 186
254 if (t !== undefined) query.transaction = t 187 static loadByUUIDOrUrl (uuid: string, url: string, t?: Sequelize.Transaction) {
188 const query: IFindOptions<VideoChannelModel> = {
189 where: {
190 [ Sequelize.Op.or ]: [
191 { uuid },
192 { url }
193 ]
194 }
195 }
255 196
256 return VideoChannel.findOne(query) 197 if (t !== undefined) query.transaction = t
257}
258 198
259loadByUrl = function (url: string, t?: Sequelize.Transaction) { 199 return VideoChannelModel.findOne(query)
260 const query: Sequelize.FindOptions<VideoChannelAttributes> = {
261 where: {
262 url
263 },
264 include: [ VideoChannel['sequelize'].models.Account ]
265 } 200 }
266 201
267 if (t !== undefined) query.transaction = t 202 static loadByHostAndUUID (fromHost: string, uuid: string, t?: Sequelize.Transaction) {
203 const query: IFindOptions<VideoChannelModel> = {
204 where: {
205 uuid
206 },
207 include: [
208 {
209 model: AccountModel,
210 include: [
211 {
212 model: ServerModel,
213 required: true,
214 where: {
215 host: fromHost
216 }
217 }
218 ]
219 }
220 ]
221 }
268 222
269 return VideoChannel.findOne(query) 223 if (t !== undefined) query.transaction = t
270} 224
225 return VideoChannelModel.findOne(query)
226 }
271 227
272loadByUUIDOrUrl = function (uuid: string, url: string, t?: Sequelize.Transaction) { 228 static loadByIdAndAccount (id: number, accountId: number) {
273 const query: Sequelize.FindOptions<VideoChannelAttributes> = { 229 const options = {
274 where: { 230 where: {
275 [Sequelize.Op.or]: [ 231 id,
276 { uuid }, 232 accountId
277 { url } 233 },
234 include: [
235 {
236 model: AccountModel,
237 include: [ { model: ServerModel, required: false } ]
238 }
278 ] 239 ]
279 } 240 }
241
242 return VideoChannelModel.findOne(options)
280 } 243 }
281 244
282 if (t !== undefined) query.transaction = t 245 static loadAndPopulateAccount (id: number) {
246 const options = {
247 include: [
248 {
249 model: AccountModel,
250 include: [ { model: ServerModel, required: false } ]
251 }
252 ]
253 }
283 254
284 return VideoChannel.findOne(query) 255 return VideoChannelModel.findById(id, options)
285} 256 }
286 257
287loadByHostAndUUID = function (fromHost: string, uuid: string, t?: Sequelize.Transaction) { 258 static loadByUUIDAndPopulateAccount (uuid: string) {
288 const query: Sequelize.FindOptions<VideoChannelAttributes> = { 259 const options = {
289 where: { 260 where: {
290 uuid 261 uuid
291 }, 262 },
292 include: [ 263 include: [
293 { 264 {
294 model: VideoChannel['sequelize'].models.Account, 265 model: AccountModel,
295 include: [ 266 include: [ { model: ServerModel, required: false } ]
296 { 267 }
297 model: VideoChannel['sequelize'].models.Server, 268 ]
298 required: true, 269 }
299 where: { 270
300 host: fromHost 271 return VideoChannelModel.findOne(options)
301 }
302 }
303 ]
304 }
305 ]
306 } 272 }
307 273
308 if (t !== undefined) query.transaction = t 274 static loadAndPopulateAccountAndVideos (id: number) {
275 const options = {
276 include: [
277 {
278 model: AccountModel,
279 include: [ { model: ServerModel, required: false } ]
280 },
281 VideoModel
282 ]
283 }
309 284
310 return VideoChannel.findOne(query) 285 return VideoChannelModel.findById(id, options)
311} 286 }
312 287
313loadByIdAndAccount = function (id: number, accountId: number) { 288 isOwned () {
314 const options = { 289 return this.remote === false
315 where: {
316 id,
317 accountId
318 },
319 include: [
320 {
321 model: VideoChannel['sequelize'].models.Account,
322 include: [ { model: VideoChannel['sequelize'].models.Server, required: false } ]
323 }
324 ]
325 } 290 }
326 291
327 return VideoChannel.findOne(options) 292 toFormattedJSON () {
328} 293 const json = {
294 id: this.id,
295 uuid: this.uuid,
296 name: this.name,
297 description: this.description,
298 isLocal: this.isOwned(),
299 createdAt: this.createdAt,
300 updatedAt: this.updatedAt
301 }
329 302
330loadAndPopulateAccount = function (id: number) { 303 if (this.Account !== undefined) {
331 const options = { 304 json[ 'owner' ] = {
332 include: [ 305 name: this.Account.name,
333 { 306 uuid: this.Account.uuid
334 model: VideoChannel['sequelize'].models.Account,
335 include: [ { model: VideoChannel['sequelize'].models.Server, required: false } ]
336 } 307 }
337 ] 308 }
309
310 if (Array.isArray(this.Videos)) {
311 json[ 'videos' ] = this.Videos.map(v => v.toFormattedJSON())
312 }
313
314 return json
338 } 315 }
339 316
340 return VideoChannel.findById(id, options) 317 toActivityPubObject () {
341} 318 let sharesObject
319 if (Array.isArray(this.VideoChannelShares)) {
320 const shares: string[] = []
342 321
343loadByUUIDAndPopulateAccount = function (uuid: string) { 322 for (const videoChannelShare of this.VideoChannelShares) {
344 const options = { 323 const shareUrl = getAnnounceActivityPubUrl(this.url, videoChannelShare.Account)
345 where: { 324 shares.push(shareUrl)
346 uuid
347 },
348 include: [
349 {
350 model: VideoChannel['sequelize'].models.Account,
351 include: [ { model: VideoChannel['sequelize'].models.Server, required: false } ]
352 } 325 }
353 ]
354 }
355 326
356 return VideoChannel.findOne(options) 327 sharesObject = activityPubCollection(shares)
357} 328 }
358 329
359loadAndPopulateAccountAndVideos = function (id: number) { 330 return {
360 const options = { 331 type: 'VideoChannel' as 'VideoChannel',
361 include: [ 332 id: this.url,
362 { 333 uuid: this.uuid,
363 model: VideoChannel['sequelize'].models.Account, 334 content: this.description,
364 include: [ { model: VideoChannel['sequelize'].models.Server, required: false } ] 335 name: this.name,
365 }, 336 published: this.createdAt.toISOString(),
366 VideoChannel['sequelize'].models.Video 337 updated: this.updatedAt.toISOString(),
367 ] 338 shares: sharesObject
339 }
368 } 340 }
369
370 return VideoChannel.findById(id, options)
371} 341}
diff --git a/server/models/video/video-file-interface.ts b/server/models/video/video-file-interface.ts
deleted file mode 100644
index c9fb8b8ae..000000000
--- a/server/models/video/video-file-interface.ts
+++ /dev/null
@@ -1,24 +0,0 @@
1import * as Sequelize from 'sequelize'
2
3export namespace VideoFileMethods {
4}
5
6export interface VideoFileClass {
7}
8
9export interface VideoFileAttributes {
10 resolution: number
11 size: number
12 infoHash?: string
13 extname: string
14
15 videoId?: number
16}
17
18export interface VideoFileInstance extends VideoFileClass, VideoFileAttributes, Sequelize.Instance<VideoFileAttributes> {
19 id: number
20 createdAt: Date
21 updatedAt: Date
22}
23
24export interface VideoFileModel extends VideoFileClass, Sequelize.Model<VideoFileInstance, VideoFileAttributes> {}
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 600141994..df4067a4e 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -1,81 +1,56 @@
1import { values } from 'lodash' 1import { values } from 'lodash'
2import * as Sequelize from 'sequelize' 2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { isVideoFileInfoHashValid, isVideoFileResolutionValid, isVideoFileSizeValid } from '../../helpers/custom-validators/videos' 3import { isVideoFileInfoHashValid, isVideoFileResolutionValid, isVideoFileSizeValid } from '../../helpers/custom-validators/videos'
4import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 4import { CONSTRAINTS_FIELDS } from '../../initializers'
5import { throwIfNotValid } from '../utils'
6import { VideoModel } from './video'
5 7
6import { addMethodsToModel } from '../utils' 8@Table({
7import { VideoFileAttributes, VideoFileInstance } from './video-file-interface' 9 tableName: 'videoFile',
8 10 indexes: [
9let VideoFile: Sequelize.Model<VideoFileInstance, VideoFileAttributes>
10
11export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
12 VideoFile = sequelize.define<VideoFileInstance, VideoFileAttributes>('VideoFile',
13 { 11 {
14 resolution: { 12 fields: [ 'videoId' ]
15 type: DataTypes.INTEGER,
16 allowNull: false,
17 validate: {
18 resolutionValid: value => {
19 const res = isVideoFileResolutionValid(value)
20 if (res === false) throw new Error('Video file resolution is not valid.')
21 }
22 }
23 },
24 size: {
25 type: DataTypes.BIGINT,
26 allowNull: false,
27 validate: {
28 sizeValid: value => {
29 const res = isVideoFileSizeValid(value)
30 if (res === false) throw new Error('Video file size is not valid.')
31 }
32 }
33 },
34 extname: {
35 type: DataTypes.ENUM(values(CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)),
36 allowNull: false
37 },
38 infoHash: {
39 type: DataTypes.STRING,
40 allowNull: false,
41 validate: {
42 infoHashValid: value => {
43 const res = isVideoFileInfoHashValid(value)
44 if (res === false) throw new Error('Video file info hash is not valid.')
45 }
46 }
47 }
48 }, 13 },
49 { 14 {
50 indexes: [ 15 fields: [ 'infoHash' ]
51 {
52 fields: [ 'videoId' ]
53 },
54 {
55 fields: [ 'infoHash' ]
56 }
57 ]
58 } 16 }
59 )
60
61 const classMethods = [
62 associate
63 ] 17 ]
64 addMethodsToModel(VideoFile, classMethods) 18})
65 19export class VideoFileModel extends Model<VideoFileModel> {
66 return VideoFile 20 @CreatedAt
67} 21 createdAt: Date
68 22
69// ------------------------------ STATICS ------------------------------ 23 @UpdatedAt
70 24 updatedAt: Date
71function associate (models) { 25
72 VideoFile.belongsTo(models.Video, { 26 @AllowNull(false)
27 @Is('VideoFileResolution', value => throwIfNotValid(value, isVideoFileResolutionValid, 'resolution'))
28 @Column
29 resolution: number
30
31 @AllowNull(false)
32 @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileSizeValid, 'size'))
33 @Column(DataType.BIGINT)
34 size: number
35
36 @AllowNull(false)
37 @Column(DataType.ENUM(values(CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)))
38 extname: string
39
40 @AllowNull(false)
41 @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
42 @Column
43 infoHash: string
44
45 @ForeignKey(() => VideoModel)
46 @Column
47 videoId: number
48
49 @BelongsTo(() => VideoModel, {
73 foreignKey: { 50 foreignKey: {
74 name: 'videoId',
75 allowNull: false 51 allowNull: false
76 }, 52 },
77 onDelete: 'CASCADE' 53 onDelete: 'CASCADE'
78 }) 54 })
55 Video: VideoModel
79} 56}
80
81// ------------------------------ METHODS ------------------------------
diff --git a/server/models/video/video-interface.ts b/server/models/video/video-interface.ts
deleted file mode 100644
index 2a63350af..000000000
--- a/server/models/video/video-interface.ts
+++ /dev/null
@@ -1,150 +0,0 @@
1import * as Bluebird from 'bluebird'
2import * as Sequelize from 'sequelize'
3import { VideoTorrentObject } from '../../../shared/models/activitypub/objects/video-torrent-object'
4import { ResultList } from '../../../shared/models/result-list.model'
5import { Video as FormattedVideo, VideoDetails as FormattedDetailsVideo } from '../../../shared/models/videos/video.model'
6import { AccountVideoRateInstance } from '../account/account-video-rate-interface'
7
8import { TagAttributes, TagInstance } from './tag-interface'
9import { VideoChannelInstance } from './video-channel-interface'
10import { VideoFileAttributes, VideoFileInstance } from './video-file-interface'
11import { VideoShareInstance } from './video-share-interface'
12
13export namespace VideoMethods {
14 export type GetThumbnailName = (this: VideoInstance) => string
15 export type GetPreviewName = (this: VideoInstance) => string
16 export type IsOwned = (this: VideoInstance) => boolean
17 export type ToFormattedJSON = (this: VideoInstance) => FormattedVideo
18 export type ToFormattedDetailsJSON = (this: VideoInstance) => FormattedDetailsVideo
19
20 export type GetOriginalFile = (this: VideoInstance) => VideoFileInstance
21 export type GetTorrentFileName = (this: VideoInstance, videoFile: VideoFileInstance) => string
22 export type GetVideoFilename = (this: VideoInstance, videoFile: VideoFileInstance) => string
23 export type CreatePreview = (this: VideoInstance, videoFile: VideoFileInstance) => Promise<string>
24 export type CreateThumbnail = (this: VideoInstance, videoFile: VideoFileInstance) => Promise<string>
25 export type GetVideoFilePath = (this: VideoInstance, videoFile: VideoFileInstance) => string
26 export type CreateTorrentAndSetInfoHash = (this: VideoInstance, videoFile: VideoFileInstance) => Promise<void>
27
28 export type ToActivityPubObject = (this: VideoInstance) => VideoTorrentObject
29
30 export type OptimizeOriginalVideofile = (this: VideoInstance) => Promise<void>
31 export type TranscodeOriginalVideofile = (this: VideoInstance, resolution: number) => Promise<void>
32 export type GetOriginalFileHeight = (this: VideoInstance) => Promise<number>
33 export type GetEmbedPath = (this: VideoInstance) => string
34 export type GetThumbnailPath = (this: VideoInstance) => string
35 export type GetPreviewPath = (this: VideoInstance) => string
36 export type GetDescriptionPath = (this: VideoInstance) => string
37 export type GetTruncatedDescription = (this: VideoInstance) => string
38 export type GetCategoryLabel = (this: VideoInstance) => string
39 export type GetLicenceLabel = (this: VideoInstance) => string
40 export type GetLanguageLabel = (this: VideoInstance) => string
41
42 export type List = () => Bluebird<VideoInstance[]>
43
44 export type ListAllAndSharedByAccountForOutbox = (
45 accountId: number,
46 start: number,
47 count: number
48 ) => Bluebird< ResultList<VideoInstance> >
49 export type ListForApi = (start: number, count: number, sort: string) => Bluebird< ResultList<VideoInstance> >
50 export type ListUserVideosForApi = (userId: number, start: number, count: number, sort: string) => Bluebird< ResultList<VideoInstance> >
51 export type SearchAndPopulateAccountAndServerAndTags = (
52 value: string,
53 start: number,
54 count: number,
55 sort: string
56 ) => Bluebird< ResultList<VideoInstance> >
57
58 export type Load = (id: number) => Bluebird<VideoInstance>
59 export type LoadByUUID = (uuid: string, t?: Sequelize.Transaction) => Bluebird<VideoInstance>
60 export type LoadByUrlAndPopulateAccount = (url: string, t?: Sequelize.Transaction) => Bluebird<VideoInstance>
61 export type LoadAndPopulateAccountAndServerAndTags = (id: number) => Bluebird<VideoInstance>
62 export type LoadByUUIDAndPopulateAccountAndServerAndTags = (uuid: string) => Bluebird<VideoInstance>
63 export type LoadByUUIDOrURL = (uuid: string, url: string, t?: Sequelize.Transaction) => Bluebird<VideoInstance>
64
65 export type RemoveThumbnail = (this: VideoInstance) => Promise<void>
66 export type RemovePreview = (this: VideoInstance) => Promise<void>
67 export type RemoveFile = (this: VideoInstance, videoFile: VideoFileInstance) => Promise<void>
68 export type RemoveTorrent = (this: VideoInstance, videoFile: VideoFileInstance) => Promise<void>
69}
70
71export interface VideoClass {
72 list: VideoMethods.List
73 listAllAndSharedByAccountForOutbox: VideoMethods.ListAllAndSharedByAccountForOutbox
74 listForApi: VideoMethods.ListForApi
75 listUserVideosForApi: VideoMethods.ListUserVideosForApi
76 load: VideoMethods.Load
77 loadAndPopulateAccountAndServerAndTags: VideoMethods.LoadAndPopulateAccountAndServerAndTags
78 loadByUUID: VideoMethods.LoadByUUID
79 loadByUrlAndPopulateAccount: VideoMethods.LoadByUrlAndPopulateAccount
80 loadByUUIDOrURL: VideoMethods.LoadByUUIDOrURL
81 loadByUUIDAndPopulateAccountAndServerAndTags: VideoMethods.LoadByUUIDAndPopulateAccountAndServerAndTags
82 searchAndPopulateAccountAndServerAndTags: VideoMethods.SearchAndPopulateAccountAndServerAndTags
83}
84
85export interface VideoAttributes {
86 id?: number
87 uuid?: string
88 name: string
89 category: number
90 licence: number
91 language: number
92 nsfw: boolean
93 description: string
94 duration: number
95 privacy: number
96 views?: number
97 likes?: number
98 dislikes?: number
99 remote: boolean
100 url?: string
101
102 createdAt?: Date
103 updatedAt?: Date
104
105 parentId?: number
106 channelId?: number
107
108 VideoChannel?: VideoChannelInstance
109 Tags?: TagInstance[]
110 VideoFiles?: VideoFileInstance[]
111 VideoShares?: VideoShareInstance[]
112 AccountVideoRates?: AccountVideoRateInstance[]
113}
114
115export interface VideoInstance extends VideoClass, VideoAttributes, Sequelize.Instance<VideoAttributes> {
116 createPreview: VideoMethods.CreatePreview
117 createThumbnail: VideoMethods.CreateThumbnail
118 createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
119 getOriginalFile: VideoMethods.GetOriginalFile
120 getPreviewName: VideoMethods.GetPreviewName
121 getPreviewPath: VideoMethods.GetPreviewPath
122 getThumbnailName: VideoMethods.GetThumbnailName
123 getThumbnailPath: VideoMethods.GetThumbnailPath
124 getTorrentFileName: VideoMethods.GetTorrentFileName
125 getVideoFilename: VideoMethods.GetVideoFilename
126 getVideoFilePath: VideoMethods.GetVideoFilePath
127 isOwned: VideoMethods.IsOwned
128 removeFile: VideoMethods.RemoveFile
129 removePreview: VideoMethods.RemovePreview
130 removeThumbnail: VideoMethods.RemoveThumbnail
131 removeTorrent: VideoMethods.RemoveTorrent
132 toActivityPubObject: VideoMethods.ToActivityPubObject
133 toFormattedJSON: VideoMethods.ToFormattedJSON
134 toFormattedDetailsJSON: VideoMethods.ToFormattedDetailsJSON
135 optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
136 transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
137 getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
138 getEmbedPath: VideoMethods.GetEmbedPath
139 getDescriptionPath: VideoMethods.GetDescriptionPath
140 getTruncatedDescription: VideoMethods.GetTruncatedDescription
141 getCategoryLabel: VideoMethods.GetCategoryLabel
142 getLicenceLabel: VideoMethods.GetLicenceLabel
143 getLanguageLabel: VideoMethods.GetLanguageLabel
144
145 setTags: Sequelize.HasManySetAssociationsMixin<TagAttributes, string>
146 addVideoFile: Sequelize.HasManyAddAssociationMixin<VideoFileAttributes, string>
147 setVideoFiles: Sequelize.HasManySetAssociationsMixin<VideoFileAttributes, string>
148}
149
150export interface VideoModel extends VideoClass, Sequelize.Model<VideoInstance, VideoAttributes> {}
diff --git a/server/models/video/video-share-interface.ts b/server/models/video/video-share-interface.ts
deleted file mode 100644
index 3946303f1..000000000
--- a/server/models/video/video-share-interface.ts
+++ /dev/null
@@ -1,30 +0,0 @@
1import * as Bluebird from 'bluebird'
2import * as Sequelize from 'sequelize'
3import { AccountInstance } from '../account/account-interface'
4import { VideoInstance } from './video-interface'
5
6export namespace VideoShareMethods {
7 export type LoadAccountsByShare = (videoId: number, t: Sequelize.Transaction) => Bluebird<AccountInstance[]>
8 export type Load = (accountId: number, videoId: number, t: Sequelize.Transaction) => Bluebird<VideoShareInstance>
9}
10
11export interface VideoShareClass {
12 loadAccountsByShare: VideoShareMethods.LoadAccountsByShare
13 load: VideoShareMethods.Load
14}
15
16export interface VideoShareAttributes {
17 accountId: number
18 videoId: number
19}
20
21export interface VideoShareInstance extends VideoShareClass, VideoShareAttributes, Sequelize.Instance<VideoShareAttributes> {
22 id: number
23 createdAt: Date
24 updatedAt: Date
25
26 Account?: AccountInstance
27 Video?: VideoInstance
28}
29
30export interface VideoShareModel extends VideoShareClass, Sequelize.Model<VideoShareInstance, VideoShareAttributes> {}
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
index 37e405fa9..01b6d3d34 100644
--- a/server/models/video/video-share.ts
+++ b/server/models/video/video-share.ts
@@ -1,84 +1,78 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { AccountModel } from '../account/account'
4import { VideoModel } from './video'
2 5
3import { addMethodsToModel } from '../utils' 6@Table({
4import { VideoShareAttributes, VideoShareInstance, VideoShareMethods } from './video-share-interface' 7 tableName: 'videoShare',
5 8 indexes: [
6let VideoShare: Sequelize.Model<VideoShareInstance, VideoShareAttributes>
7let loadAccountsByShare: VideoShareMethods.LoadAccountsByShare
8let load: VideoShareMethods.Load
9
10export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
11 VideoShare = sequelize.define<VideoShareInstance, VideoShareAttributes>('VideoShare',
12 { },
13 { 9 {
14 indexes: [ 10 fields: [ 'accountId' ]
15 { 11 },
16 fields: [ 'accountId' ] 12 {
17 }, 13 fields: [ 'videoId' ]
18 {
19 fields: [ 'videoId' ]
20 }
21 ]
22 } 14 }
23 )
24
25 const classMethods = [
26 associate,
27 loadAccountsByShare,
28 load
29 ] 15 ]
30 addMethodsToModel(VideoShare, classMethods) 16})
17export class VideoShareModel extends Model<VideoShareModel> {
18 @CreatedAt
19 createdAt: Date
31 20
32 return VideoShare 21 @UpdatedAt
33} 22 updatedAt: Date
34 23
35// ------------------------------ METHODS ------------------------------ 24 @ForeignKey(() => AccountModel)
25 @Column
26 accountId: number
36 27
37function associate (models) { 28 @BelongsTo(() => AccountModel, {
38 VideoShare.belongsTo(models.Account, {
39 foreignKey: { 29 foreignKey: {
40 name: 'accountId',
41 allowNull: false 30 allowNull: false
42 }, 31 },
43 onDelete: 'cascade' 32 onDelete: 'cascade'
44 }) 33 })
34 Account: AccountModel
45 35
46 VideoShare.belongsTo(models.Video, { 36 @ForeignKey(() => VideoModel)
37 @Column
38 videoId: number
39
40 @BelongsTo(() => VideoModel, {
47 foreignKey: { 41 foreignKey: {
48 name: 'videoId', 42 allowNull: false
49 allowNull: true
50 }, 43 },
51 onDelete: 'cascade' 44 onDelete: 'cascade'
52 }) 45 })
53} 46 Video: VideoModel
54
55load = function (accountId: number, videoId: number, t: Sequelize.Transaction) {
56 return VideoShare.findOne({
57 where: {
58 accountId,
59 videoId
60 },
61 include: [
62 VideoShare['sequelize'].models.Account
63 ],
64 transaction: t
65 })
66}
67 47
68loadAccountsByShare = function (videoId: number, t: Sequelize.Transaction) { 48 static load (accountId: number, videoId: number, t: Sequelize.Transaction) {
69 const query = { 49 return VideoShareModel.findOne({
70 where: { 50 where: {
71 videoId 51 accountId,
72 }, 52 videoId
73 include: [ 53 },
74 { 54 include: [
75 model: VideoShare['sequelize'].models.Account, 55 AccountModel
76 required: true 56 ],
77 } 57 transaction: t
78 ], 58 })
79 transaction: t
80 } 59 }
81 60
82 return VideoShare.findAll(query) 61 static loadAccountsByShare (videoId: number, t: Sequelize.Transaction) {
83 .then(res => res.map(r => r.Account)) 62 const query = {
63 where: {
64 videoId
65 },
66 include: [
67 {
68 model: AccountModel,
69 required: true
70 }
71 ],
72 transaction: t
73 }
74
75 return VideoShareModel.findAll(query)
76 .then(res => res.map(r => r.Account))
77 }
84} 78}
diff --git a/server/models/video/video-tag-interface.ts b/server/models/video/video-tag-interface.ts
deleted file mode 100644
index f928cecff..000000000
--- a/server/models/video/video-tag-interface.ts
+++ /dev/null
@@ -1,18 +0,0 @@
1import * as Sequelize from 'sequelize'
2
3export namespace VideoTagMethods {
4}
5
6export interface VideoTagClass {
7}
8
9export interface VideoTagAttributes {
10}
11
12export interface VideoTagInstance extends VideoTagClass, VideoTagAttributes, Sequelize.Instance<VideoTagAttributes> {
13 id: number
14 createdAt: Date
15 updatedAt: Date
16}
17
18export interface VideoTagModel extends VideoTagClass, Sequelize.Model<VideoTagInstance, VideoTagAttributes> {}
diff --git a/server/models/video/video-tag.ts b/server/models/video/video-tag.ts
index ac45374f8..ca15e3426 100644
--- a/server/models/video/video-tag.ts
+++ b/server/models/video/video-tag.ts
@@ -1,23 +1,30 @@
1import * as Sequelize from 'sequelize' 1import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { TagModel } from './tag'
3import { VideoModel } from './video'
2 4
3import { 5@Table({
4 VideoTagInstance, 6 tableName: 'videoTag',
5 VideoTagAttributes 7 indexes: [
6} from './video-tag-interface' 8 {
9 fields: [ 'videoId' ]
10 },
11 {
12 fields: [ 'tagId' ]
13 }
14 ]
15})
16export class VideoTagModel extends Model<VideoTagModel> {
17 @CreatedAt
18 createdAt: Date
7 19
8let VideoTag: Sequelize.Model<VideoTagInstance, VideoTagAttributes> 20 @UpdatedAt
21 updatedAt: Date
9 22
10export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) { 23 @ForeignKey(() => VideoModel)
11 VideoTag = sequelize.define<VideoTagInstance, VideoTagAttributes>('VideoTag', {}, { 24 @Column
12 indexes: [ 25 videoId: number
13 {
14 fields: [ 'videoId' ]
15 },
16 {
17 fields: [ 'tagId' ]
18 }
19 ]
20 })
21 26
22 return VideoTag 27 @ForeignKey(() => TagModel)
28 @Column
29 tagId: number
23} 30}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index d46fdeebe..9e26f9bbe 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -4,21 +4,52 @@ import * 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 * as Sequelize from 'sequelize'
7import {
8 AfterDestroy,
9 AllowNull,
10 BelongsTo,
11 BelongsToMany,
12 Column,
13 CreatedAt,
14 DataType,
15 Default,
16 ForeignKey,
17 HasMany,
18 IFindOptions,
19 Is,
20 IsInt,
21 IsUUID,
22 Min,
23 Model,
24 Table,
25 UpdatedAt
26} from 'sequelize-typescript'
27import { IIncludeOptions } from 'sequelize-typescript/lib/interfaces/IIncludeOptions'
7import { VideoPrivacy, VideoResolution } from '../../../shared' 28import { VideoPrivacy, VideoResolution } from '../../../shared'
8import { VideoTorrentObject } from '../../../shared/models/activitypub/objects/video-torrent-object' 29import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
9import { activityPubCollection } from '../../helpers/activitypub' 30import {
10import { createTorrentPromise, renamePromise, statPromise, unlinkPromise, writeFilePromise } from '../../helpers/core-utils' 31 activityPubCollection,
11import { isVideoCategoryValid, isVideoLanguageValid, isVideoPrivacyValid } from '../../helpers/custom-validators/videos' 32 createTorrentPromise,
12import { generateImageFromVideoFile, getVideoFileHeight, transcode } from '../../helpers/ffmpeg-utils' 33 generateImageFromVideoFile,
34 getVideoFileHeight,
35 logger,
36 renamePromise,
37 statPromise,
38 transcode,
39 unlinkPromise,
40 writeFilePromise
41} from '../../helpers'
42import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub'
13import { 43import {
14 isActivityPubUrlValid, 44 isVideoCategoryValid,
15 isVideoDescriptionValid, 45 isVideoDescriptionValid,
16 isVideoDurationValid, 46 isVideoDurationValid,
47 isVideoLanguageValid,
17 isVideoLicenceValid, 48 isVideoLicenceValid,
18 isVideoNameValid, 49 isVideoNameValid,
19 isVideoNSFWValid 50 isVideoNSFWValid,
20} from '../../helpers/index' 51 isVideoPrivacyValid
21import { logger } from '../../helpers/logger' 52} from '../../helpers/custom-validators/videos'
22import { 53import {
23 API_VERSION, 54 API_VERSION,
24 CONFIG, 55 CONFIG,
@@ -31,1169 +62,1025 @@ import {
31 VIDEO_LANGUAGES, 62 VIDEO_LANGUAGES,
32 VIDEO_LICENCES, 63 VIDEO_LICENCES,
33 VIDEO_PRIVACIES 64 VIDEO_PRIVACIES
34} from '../../initializers/constants' 65} from '../../initializers'
35import { getAnnounceActivityPubUrl } from '../../lib/activitypub/url' 66import { getAnnounceActivityPubUrl } from '../../lib/activitypub'
36import { sendDeleteVideo } from '../../lib/index' 67import { sendDeleteVideo } from '../../lib/index'
37import { addMethodsToModel, getSort } from '../utils' 68import { AccountModel } from '../account/account'
38import { TagInstance } from './tag-interface' 69import { AccountVideoRateModel } from '../account/account-video-rate'
39import { VideoFileInstance, VideoFileModel } from './video-file-interface' 70import { ServerModel } from '../server/server'
40import { VideoAttributes, VideoInstance, VideoMethods } from './video-interface' 71import { getSort, throwIfNotValid } from '../utils'
41 72import { TagModel } from './tag'
42let Video: Sequelize.Model<VideoInstance, VideoAttributes> 73import { VideoAbuseModel } from './video-abuse'
43let getOriginalFile: VideoMethods.GetOriginalFile 74import { VideoChannelModel } from './video-channel'
44let getVideoFilename: VideoMethods.GetVideoFilename 75import { VideoFileModel } from './video-file'
45let getThumbnailName: VideoMethods.GetThumbnailName 76import { VideoShareModel } from './video-share'
46let getThumbnailPath: VideoMethods.GetThumbnailPath 77import { VideoTagModel } from './video-tag'
47let getPreviewName: VideoMethods.GetPreviewName 78
48let getPreviewPath: VideoMethods.GetPreviewPath 79@Table({
49let getTorrentFileName: VideoMethods.GetTorrentFileName 80 tableName: 'video',
50let isOwned: VideoMethods.IsOwned 81 indexes: [
51let toFormattedJSON: VideoMethods.ToFormattedJSON
52let toFormattedDetailsJSON: VideoMethods.ToFormattedDetailsJSON
53let toActivityPubObject: VideoMethods.ToActivityPubObject
54let optimizeOriginalVideofile: VideoMethods.OptimizeOriginalVideofile
55let transcodeOriginalVideofile: VideoMethods.TranscodeOriginalVideofile
56let createPreview: VideoMethods.CreatePreview
57let createThumbnail: VideoMethods.CreateThumbnail
58let getVideoFilePath: VideoMethods.GetVideoFilePath
59let createTorrentAndSetInfoHash: VideoMethods.CreateTorrentAndSetInfoHash
60let getOriginalFileHeight: VideoMethods.GetOriginalFileHeight
61let getEmbedPath: VideoMethods.GetEmbedPath
62let getDescriptionPath: VideoMethods.GetDescriptionPath
63let getTruncatedDescription: VideoMethods.GetTruncatedDescription
64let getCategoryLabel: VideoMethods.GetCategoryLabel
65let getLicenceLabel: VideoMethods.GetLicenceLabel
66let getLanguageLabel: VideoMethods.GetLanguageLabel
67
68let list: VideoMethods.List
69let listForApi: VideoMethods.ListForApi
70let listAllAndSharedByAccountForOutbox: VideoMethods.ListAllAndSharedByAccountForOutbox
71let listUserVideosForApi: VideoMethods.ListUserVideosForApi
72let load: VideoMethods.Load
73let loadByUrlAndPopulateAccount: VideoMethods.LoadByUrlAndPopulateAccount
74let loadByUUID: VideoMethods.LoadByUUID
75let loadByUUIDOrURL: VideoMethods.LoadByUUIDOrURL
76let loadAndPopulateAccountAndServerAndTags: VideoMethods.LoadAndPopulateAccountAndServerAndTags
77let loadByUUIDAndPopulateAccountAndServerAndTags: VideoMethods.LoadByUUIDAndPopulateAccountAndServerAndTags
78let searchAndPopulateAccountAndServerAndTags: VideoMethods.SearchAndPopulateAccountAndServerAndTags
79let removeThumbnail: VideoMethods.RemoveThumbnail
80let removePreview: VideoMethods.RemovePreview
81let removeFile: VideoMethods.RemoveFile
82let removeTorrent: VideoMethods.RemoveTorrent
83
84export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
85 Video = sequelize.define<VideoInstance, VideoAttributes>('Video',
86 { 82 {
87 uuid: { 83 fields: [ 'name' ]
88 type: DataTypes.UUID,
89 defaultValue: DataTypes.UUIDV4,
90 allowNull: false,
91 validate: {
92 isUUID: 4
93 }
94 },
95 name: {
96 type: DataTypes.STRING,
97 allowNull: false,
98 validate: {
99 nameValid: value => {
100 const res = isVideoNameValid(value)
101 if (res === false) throw new Error('Video name is not valid.')
102 }
103 }
104 },
105 category: {
106 type: DataTypes.INTEGER,
107 allowNull: true,
108 defaultValue: null,
109 validate: {
110 categoryValid: value => {
111 const res = isVideoCategoryValid(value)
112 if (res === false) throw new Error('Video category is not valid.')
113 }
114 }
115 },
116 licence: {
117 type: DataTypes.INTEGER,
118 allowNull: true,
119 defaultValue: null,
120 validate: {
121 licenceValid: value => {
122 const res = isVideoLicenceValid(value)
123 if (res === false) throw new Error('Video licence is not valid.')
124 }
125 }
126 },
127 language: {
128 type: DataTypes.INTEGER,
129 allowNull: true,
130 defaultValue: null,
131 validate: {
132 languageValid: value => {
133 const res = isVideoLanguageValid(value)
134 if (res === false) throw new Error('Video language is not valid.')
135 }
136 }
137 },
138 privacy: {
139 type: DataTypes.INTEGER,
140 allowNull: false,
141 validate: {
142 privacyValid: value => {
143 const res = isVideoPrivacyValid(value)
144 if (res === false) throw new Error('Video privacy is not valid.')
145 }
146 }
147 },
148 nsfw: {
149 type: DataTypes.BOOLEAN,
150 allowNull: false,
151 validate: {
152 nsfwValid: value => {
153 const res = isVideoNSFWValid(value)
154 if (res === false) throw new Error('Video nsfw attribute is not valid.')
155 }
156 }
157 },
158 description: {
159 type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max),
160 allowNull: true,
161 defaultValue: null,
162 validate: {
163 descriptionValid: value => {
164 const res = isVideoDescriptionValid(value)
165 if (res === false) throw new Error('Video description is not valid.')
166 }
167 }
168 },
169 duration: {
170 type: DataTypes.INTEGER,
171 allowNull: false,
172 validate: {
173 durationValid: value => {
174 const res = isVideoDurationValid(value)
175 if (res === false) throw new Error('Video duration is not valid.')
176 }
177 }
178 },
179 views: {
180 type: DataTypes.INTEGER,
181 allowNull: false,
182 defaultValue: 0,
183 validate: {
184 min: 0,
185 isInt: true
186 }
187 },
188 likes: {
189 type: DataTypes.INTEGER,
190 allowNull: false,
191 defaultValue: 0,
192 validate: {
193 min: 0,
194 isInt: true
195 }
196 },
197 dislikes: {
198 type: DataTypes.INTEGER,
199 allowNull: false,
200 defaultValue: 0,
201 validate: {
202 min: 0,
203 isInt: true
204 }
205 },
206 remote: {
207 type: DataTypes.BOOLEAN,
208 allowNull: false,
209 defaultValue: false
210 },
211 url: {
212 type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max),
213 allowNull: false,
214 validate: {
215 urlValid: value => {
216 const res = isActivityPubUrlValid(value)
217 if (res === false) throw new Error('Video URL is not valid.')
218 }
219 }
220 }
221 }, 84 },
222 { 85 {
223 indexes: [ 86 fields: [ 'createdAt' ]
224 { 87 },
225 fields: [ 'name' ] 88 {
226 }, 89 fields: [ 'duration' ]
227 { 90 },
228 fields: [ 'createdAt' ] 91 {
229 }, 92 fields: [ 'views' ]
230 { 93 },
231 fields: [ 'duration' ] 94 {
232 }, 95 fields: [ 'likes' ]
233 { 96 },
234 fields: [ 'views' ] 97 {
235 }, 98 fields: [ 'uuid' ]
236 { 99 },
237 fields: [ 'likes' ] 100 {
238 }, 101 fields: [ 'channelId' ]
239 {
240 fields: [ 'uuid' ]
241 },
242 {
243 fields: [ 'channelId' ]
244 }
245 ],
246 hooks: {
247 afterDestroy
248 }
249 } 102 }
250 )
251
252 const classMethods = [
253 associate,
254
255 list,
256 listAllAndSharedByAccountForOutbox,
257 listForApi,
258 listUserVideosForApi,
259 load,
260 loadByUrlAndPopulateAccount,
261 loadAndPopulateAccountAndServerAndTags,
262 loadByUUIDOrURL,
263 loadByUUID,
264 loadByUUIDAndPopulateAccountAndServerAndTags,
265 searchAndPopulateAccountAndServerAndTags
266 ]
267 const instanceMethods = [
268 createPreview,
269 createThumbnail,
270 createTorrentAndSetInfoHash,
271 getPreviewName,
272 getPreviewPath,
273 getThumbnailName,
274 getThumbnailPath,
275 getTorrentFileName,
276 getVideoFilename,
277 getVideoFilePath,
278 getOriginalFile,
279 isOwned,
280 removeFile,
281 removePreview,
282 removeThumbnail,
283 removeTorrent,
284 toActivityPubObject,
285 toFormattedJSON,
286 toFormattedDetailsJSON,
287 optimizeOriginalVideofile,
288 transcodeOriginalVideofile,
289 getOriginalFileHeight,
290 getEmbedPath,
291 getTruncatedDescription,
292 getDescriptionPath,
293 getCategoryLabel,
294 getLicenceLabel,
295 getLanguageLabel
296 ] 103 ]
297 addMethodsToModel(Video, classMethods, instanceMethods) 104})
298 105export class VideoModel extends Model<VideoModel> {
299 return Video 106
300} 107 @AllowNull(false)
301 108 @Default(DataType.UUIDV4)
302// ------------------------------ METHODS ------------------------------ 109 @IsUUID(4)
303 110 @Column(DataType.UUID)
304function associate (models) { 111 uuid: string
305 Video.belongsTo(models.VideoChannel, { 112
113 @AllowNull(false)
114 @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
115 @Column
116 name: string
117
118 @AllowNull(true)
119 @Default(null)
120 @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category'))
121 @Column
122 category: number
123
124 @AllowNull(true)
125 @Default(null)
126 @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence'))
127 @Column
128 licence: number
129
130 @AllowNull(true)
131 @Default(null)
132 @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language'))
133 @Column
134 language: number
135
136 @AllowNull(false)
137 @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
138 @Column
139 privacy: number
140
141 @AllowNull(false)
142 @Is('VideoNSFW', value => throwIfNotValid(value, isVideoNSFWValid, 'NSFW boolean'))
143 @Column
144 nsfw: boolean
145
146 @AllowNull(true)
147 @Default(null)
148 @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description'))
149 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
150 description: string
151
152 @AllowNull(false)
153 @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
154 @Column
155 duration: number
156
157 @AllowNull(false)
158 @Default(0)
159 @IsInt
160 @Min(0)
161 @Column
162 views: number
163
164 @AllowNull(false)
165 @Default(0)
166 @IsInt
167 @Min(0)
168 @Column
169 likes: number
170
171 @AllowNull(false)
172 @Default(0)
173 @IsInt
174 @Min(0)
175 @Column
176 dislikes: number
177
178 @AllowNull(false)
179 @Column
180 remote: boolean
181
182 @AllowNull(false)
183 @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
184 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
185 url: string
186
187 @CreatedAt
188 createdAt: Date
189
190 @UpdatedAt
191 updatedAt: Date
192
193 @ForeignKey(() => VideoChannelModel)
194 @Column
195 channelId: number
196
197 @BelongsTo(() => VideoChannelModel, {
306 foreignKey: { 198 foreignKey: {
307 name: 'channelId',
308 allowNull: false 199 allowNull: false
309 }, 200 },
310 onDelete: 'cascade' 201 onDelete: 'cascade'
311 }) 202 })
203 VideoChannel: VideoChannelModel
312 204
313 Video.belongsToMany(models.Tag, { 205 @BelongsToMany(() => TagModel, {
314 foreignKey: 'videoId', 206 foreignKey: 'videoId',
315 through: models.VideoTag, 207 through: () => VideoTagModel,
316 onDelete: 'cascade' 208 onDelete: 'CASCADE'
317 }) 209 })
210 Tags: TagModel[]
318 211
319 Video.hasMany(models.VideoAbuse, { 212 @HasMany(() => VideoAbuseModel, {
320 foreignKey: { 213 foreignKey: {
321 name: 'videoId', 214 name: 'videoId',
322 allowNull: false 215 allowNull: false
323 }, 216 },
324 onDelete: 'cascade' 217 onDelete: 'cascade'
325 }) 218 })
219 VideoAbuses: VideoAbuseModel[]
326 220
327 Video.hasMany(models.VideoFile, { 221 @HasMany(() => VideoFileModel, {
328 foreignKey: { 222 foreignKey: {
329 name: 'videoId', 223 name: 'videoId',
330 allowNull: false 224 allowNull: false
331 }, 225 },
332 onDelete: 'cascade' 226 onDelete: 'cascade'
333 }) 227 })
228 VideoFiles: VideoFileModel[]
334 229
335 Video.hasMany(models.VideoShare, { 230 @HasMany(() => VideoShareModel, {
336 foreignKey: { 231 foreignKey: {
337 name: 'videoId', 232 name: 'videoId',
338 allowNull: false 233 allowNull: false
339 }, 234 },
340 onDelete: 'cascade' 235 onDelete: 'cascade'
341 }) 236 })
237 VideoShares: VideoShareModel[]
342 238
343 Video.hasMany(models.AccountVideoRate, { 239 @HasMany(() => AccountVideoRateModel, {
344 foreignKey: { 240 foreignKey: {
345 name: 'videoId', 241 name: 'videoId',
346 allowNull: false 242 allowNull: false
347 }, 243 },
348 onDelete: 'cascade' 244 onDelete: 'cascade'
349 }) 245 })
350} 246 AccountVideoRates: AccountVideoRateModel[]
351
352function afterDestroy (video: VideoInstance) {
353 const tasks = []
354 247
355 tasks.push( 248 @AfterDestroy
356 video.removeThumbnail() 249 static removeFilesAndSendDelete (instance: VideoModel) {
357 ) 250 const tasks = []
358 251
359 if (video.isOwned()) {
360 tasks.push( 252 tasks.push(
361 video.removePreview(), 253 instance.removeThumbnail()
362 sendDeleteVideo(video, undefined)
363 ) 254 )
364 255
365 // Remove physical files and torrents 256 if (instance.isOwned()) {
366 video.VideoFiles.forEach(file => { 257 tasks.push(
367 tasks.push(video.removeFile(file)) 258 instance.removePreview(),
368 tasks.push(video.removeTorrent(file)) 259 sendDeleteVideo(instance, undefined)
369 }) 260 )
370 }
371
372 return Promise.all(tasks)
373 .catch(err => {
374 logger.error('Some errors when removing files of video %s in after destroy hook.', video.uuid, err)
375 })
376}
377
378getOriginalFile = function (this: VideoInstance) {
379 if (Array.isArray(this.VideoFiles) === false) return undefined
380 261
381 // The original file is the file that have the higher resolution 262 // Remove physical files and torrents
382 return maxBy(this.VideoFiles, file => file.resolution) 263 instance.VideoFiles.forEach(file => {
383} 264 tasks.push(instance.removeFile(file))
265 tasks.push(instance.removeTorrent(file))
266 })
267 }
384 268
385getVideoFilename = function (this: VideoInstance, videoFile: VideoFileInstance) { 269 return Promise.all(tasks)
386 return this.uuid + '-' + videoFile.resolution + videoFile.extname 270 .catch(err => {
387} 271 logger.error('Some errors when removing files of video %s in after destroy hook.', instance.uuid, err)
272 })
273 }
388 274
389getThumbnailName = function (this: VideoInstance) { 275 static list () {
390 // We always have a copy of the thumbnail 276 const query = {
391 const extension = '.jpg' 277 include: [ VideoFileModel ]
392 return this.uuid + extension 278 }
393}
394 279
395getPreviewName = function (this: VideoInstance) { 280 return VideoModel.findAll(query)
396 const extension = '.jpg' 281 }
397 return this.uuid + extension
398}
399 282
400getTorrentFileName = function (this: VideoInstance, videoFile: VideoFileInstance) { 283 static listAllAndSharedByAccountForOutbox (accountId: number, start: number, count: number) {
401 const extension = '.torrent' 284 function getRawQuery (select: string) {
402 return this.uuid + '-' + videoFile.resolution + extension 285 const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
403} 286 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
287 'WHERE "VideoChannel"."accountId" = ' + accountId
288 const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
289 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
290 'WHERE "VideoShare"."accountId" = ' + accountId
404 291
405isOwned = function (this: VideoInstance) { 292 return `(${queryVideo}) UNION (${queryVideoShare})`
406 return this.remote === false 293 }
407}
408 294
409createPreview = function (this: VideoInstance, videoFile: VideoFileInstance) { 295 const rawQuery = getRawQuery('"Video"."id"')
410 const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height 296 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
297
298 const query = {
299 distinct: true,
300 offset: start,
301 limit: count,
302 order: [ getSort('createdAt'), [ 'Tags', 'name', 'ASC' ] ],
303 where: {
304 id: {
305 [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
306 }
307 },
308 include: [
309 {
310 model: VideoShareModel,
311 required: false,
312 where: {
313 [Sequelize.Op.and]: [
314 {
315 id: {
316 [Sequelize.Op.not]: null
317 }
318 },
319 {
320 accountId
321 }
322 ]
323 },
324 include: [ AccountModel ]
325 },
326 {
327 model: VideoChannelModel,
328 required: true,
329 include: [
330 {
331 model: AccountModel,
332 required: true
333 }
334 ]
335 },
336 {
337 model: AccountVideoRateModel,
338 include: [ AccountModel ]
339 },
340 VideoFileModel,
341 TagModel
342 ]
343 }
411 344
412 return generateImageFromVideoFile( 345 return Bluebird.all([
413 this.getVideoFilePath(videoFile), 346 // FIXME: typing issue
414 CONFIG.STORAGE.PREVIEWS_DIR, 347 VideoModel.findAll(query as any),
415 this.getPreviewName(), 348 VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
416 imageSize 349 ]).then(([ rows, totals ]) => {
417 ) 350 // totals: totalVideos + totalVideoShares
418} 351 let totalVideos = 0
352 let totalVideoShares = 0
353 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
354 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
355
356 const total = totalVideos + totalVideoShares
357 return {
358 data: rows,
359 total: total
360 }
361 })
362 }
419 363
420createThumbnail = function (this: VideoInstance, videoFile: VideoFileInstance) { 364 static listUserVideosForApi (userId: number, start: number, count: number, sort: string) {
421 const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height 365 const query = {
366 distinct: true,
367 offset: start,
368 limit: count,
369 order: [ getSort(sort), [ 'Tags', 'name', 'ASC' ] ],
370 include: [
371 {
372 model: VideoChannelModel,
373 required: true,
374 include: [
375 {
376 model: AccountModel,
377 where: {
378 userId
379 },
380 required: true
381 }
382 ]
383 },
384 TagModel
385 ]
386 }
422 387
423 return generateImageFromVideoFile( 388 return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
424 this.getVideoFilePath(videoFile), 389 return {
425 CONFIG.STORAGE.THUMBNAILS_DIR, 390 data: rows,
426 this.getThumbnailName(), 391 total: count
427 imageSize 392 }
428 ) 393 })
429} 394 }
430 395
431getVideoFilePath = function (this: VideoInstance, videoFile: VideoFileInstance) { 396 static listForApi (start: number, count: number, sort: string) {
432 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile)) 397 const query = {
433} 398 distinct: true,
399 offset: start,
400 limit: count,
401 order: [ getSort(sort), [ 'Tags', 'name', 'ASC' ] ],
402 include: [
403 {
404 model: VideoChannelModel,
405 required: true,
406 include: [
407 {
408 model: AccountModel,
409 required: true,
410 include: [
411 {
412 model: ServerModel,
413 required: false
414 }
415 ]
416 }
417 ]
418 },
419 TagModel
420 ],
421 where: this.createBaseVideosWhere()
422 }
434 423
435createTorrentAndSetInfoHash = async function (this: VideoInstance, videoFile: VideoFileInstance) { 424 return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
436 const options = { 425 return {
437 announceList: [ 426 data: rows,
438 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ] 427 total: count
439 ], 428 }
440 urlList: [ 429 })
441 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
442 ]
443 } 430 }
444 431
445 const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options) 432 static load (id: number) {
433 return VideoModel.findById(id)
434 }
446 435
447 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile)) 436 static loadByUUID (uuid: string, t?: Sequelize.Transaction) {
448 logger.info('Creating torrent %s.', filePath) 437 const query: IFindOptions<VideoModel> = {
438 where: {
439 uuid
440 },
441 include: [ VideoFileModel ]
442 }
449 443
450 await writeFilePromise(filePath, torrent) 444 if (t !== undefined) query.transaction = t
451 445
452 const parsedTorrent = parseTorrent(torrent) 446 return VideoModel.findOne(query)
453 videoFile.infoHash = parsedTorrent.infoHash 447 }
454}
455 448
456getEmbedPath = function (this: VideoInstance) { 449 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
457 return '/videos/embed/' + this.uuid 450 const query: IFindOptions<VideoModel> = {
458} 451 where: {
452 url
453 },
454 include: [
455 VideoFileModel,
456 {
457 model: VideoChannelModel,
458 include: [ AccountModel ]
459 }
460 ]
461 }
459 462
460getThumbnailPath = function (this: VideoInstance) { 463 if (t !== undefined) query.transaction = t
461 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
462}
463 464
464getPreviewPath = function (this: VideoInstance) { 465 return VideoModel.findOne(query)
465 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) 466 }
466}
467 467
468toFormattedJSON = function (this: VideoInstance) { 468 static loadByUUIDOrURL (uuid: string, url: string, t?: Sequelize.Transaction) {
469 let serverHost 469 const query: IFindOptions<VideoModel> = {
470 where: {
471 [Sequelize.Op.or]: [
472 { uuid },
473 { url }
474 ]
475 },
476 include: [ VideoFileModel ]
477 }
470 478
471 if (this.VideoChannel.Account.Server) { 479 if (t !== undefined) query.transaction = t
472 serverHost = this.VideoChannel.Account.Server.host
473 } else {
474 // It means it's our video
475 serverHost = CONFIG.WEBSERVER.HOST
476 }
477 480
478 const json = { 481 return VideoModel.findOne(query)
479 id: this.id,
480 uuid: this.uuid,
481 name: this.name,
482 category: this.category,
483 categoryLabel: this.getCategoryLabel(),
484 licence: this.licence,
485 licenceLabel: this.getLicenceLabel(),
486 language: this.language,
487 languageLabel: this.getLanguageLabel(),
488 nsfw: this.nsfw,
489 description: this.getTruncatedDescription(),
490 serverHost,
491 isLocal: this.isOwned(),
492 accountName: this.VideoChannel.Account.name,
493 duration: this.duration,
494 views: this.views,
495 likes: this.likes,
496 dislikes: this.dislikes,
497 tags: map<TagInstance, string>(this.Tags, 'name'),
498 thumbnailPath: this.getThumbnailPath(),
499 previewPath: this.getPreviewPath(),
500 embedPath: this.getEmbedPath(),
501 createdAt: this.createdAt,
502 updatedAt: this.updatedAt
503 } 482 }
504 483
505 return json 484 static loadAndPopulateAccountAndServerAndTags (id: number) {
506} 485 const options = {
486 order: [ [ 'Tags', 'name', 'ASC' ] ],
487 include: [
488 {
489 model: VideoChannelModel,
490 include: [
491 {
492 model: AccountModel,
493 include: [ { model: ServerModel, required: false } ]
494 }
495 ]
496 },
497 {
498 model: AccountVideoRateModel,
499 include: [ AccountModel ]
500 },
501 {
502 model: VideoShareModel,
503 include: [ AccountModel ]
504 },
505 TagModel,
506 VideoFileModel
507 ]
508 }
507 509
508toFormattedDetailsJSON = function (this: VideoInstance) { 510 return VideoModel.findById(id, options)
509 const formattedJson = this.toFormattedJSON() 511 }
510 512
511 // Maybe our server is not up to date and there are new privacy settings since our version 513 static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string) {
512 let privacyLabel = VIDEO_PRIVACIES[this.privacy] 514 const options = {
513 if (!privacyLabel) privacyLabel = 'Unknown' 515 order: [ [ 'Tags', 'name', 'ASC' ] ],
516 where: {
517 uuid
518 },
519 include: [
520 {
521 model: VideoChannelModel,
522 include: [
523 {
524 model: AccountModel,
525 include: [ { model: ServerModel, required: false } ]
526 }
527 ]
528 },
529 {
530 model: AccountVideoRateModel,
531 include: [ AccountModel ]
532 },
533 {
534 model: VideoShareModel,
535 include: [ AccountModel ]
536 },
537 TagModel,
538 VideoFileModel
539 ]
540 }
514 541
515 const detailsJson = { 542 return VideoModel.findOne(options)
516 privacyLabel,
517 privacy: this.privacy,
518 descriptionPath: this.getDescriptionPath(),
519 channel: this.VideoChannel.toFormattedJSON(),
520 account: this.VideoChannel.Account.toFormattedJSON(),
521 files: []
522 } 543 }
523 544
524 // Format and sort video files 545 static searchAndPopulateAccountAndServerAndTags (value: string, start: number, count: number, sort: string) {
525 const { baseUrlHttp, baseUrlWs } = getBaseUrls(this) 546 const serverInclude: IIncludeOptions = {
526 detailsJson.files = this.VideoFiles 547 model: ServerModel,
527 .map(videoFile => { 548 required: false
528 let resolutionLabel = videoFile.resolution + 'p' 549 }
529
530 const videoFileJson = {
531 resolution: videoFile.resolution,
532 resolutionLabel,
533 magnetUri: generateMagnetUri(this, videoFile, baseUrlHttp, baseUrlWs),
534 size: videoFile.size,
535 torrentUrl: getTorrentUrl(this, videoFile, baseUrlHttp),
536 fileUrl: getVideoFileUrl(this, videoFile, baseUrlHttp)
537 }
538
539 return videoFileJson
540 })
541 .sort((a, b) => {
542 if (a.resolution < b.resolution) return 1
543 if (a.resolution === b.resolution) return 0
544 return -1
545 })
546
547 return Object.assign(formattedJson, detailsJson)
548}
549
550toActivityPubObject = function (this: VideoInstance) {
551 const { baseUrlHttp, baseUrlWs } = getBaseUrls(this)
552 if (!this.Tags) this.Tags = []
553 550
554 const tag = this.Tags.map(t => ({ 551 const accountInclude: IIncludeOptions = {
555 type: 'Hashtag' as 'Hashtag', 552 model: AccountModel,
556 name: t.name 553 include: [ serverInclude ]
557 })) 554 }
558 555
559 let language 556 const videoChannelInclude: IIncludeOptions = {
560 if (this.language) { 557 model: VideoChannelModel,
561 language = { 558 include: [ accountInclude ],
562 identifier: this.language + '', 559 required: true
563 name: this.getLanguageLabel()
564 } 560 }
565 }
566 561
567 let category 562 const tagInclude: IIncludeOptions = {
568 if (this.category) { 563 model: TagModel
569 category = {
570 identifier: this.category + '',
571 name: this.getCategoryLabel()
572 } 564 }
573 }
574 565
575 let licence 566 const query: IFindOptions<VideoModel> = {
576 if (this.licence) { 567 distinct: true,
577 licence = { 568 where: this.createBaseVideosWhere(),
578 identifier: this.licence + '', 569 offset: start,
579 name: this.getLicenceLabel() 570 limit: count,
571 order: [ getSort(sort), [ 'Tags', 'name', 'ASC' ] ]
580 } 572 }
581 }
582 573
583 let likesObject 574 // TODO: search on tags too
584 let dislikesObject 575 // const escapedValue = Video['sequelize'].escape('%' + value + '%')
576 // query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal(
577 // `(SELECT "VideoTags"."videoId"
578 // FROM "Tags"
579 // INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
580 // WHERE name ILIKE ${escapedValue}
581 // )`
582 // )
583
584 // TODO: search on account too
585 // accountInclude.where = {
586 // name: {
587 // [Sequelize.Op.iLike]: '%' + value + '%'
588 // }
589 // }
590 query.where['name'] = {
591 [Sequelize.Op.iLike]: '%' + value + '%'
592 }
585 593
586 if (Array.isArray(this.AccountVideoRates)) { 594 query.include = [
587 const likes: string[] = [] 595 videoChannelInclude, tagInclude
588 const dislikes: string[] = [] 596 ]
589 597
590 for (const rate of this.AccountVideoRates) { 598 return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
591 if (rate.type === 'like') { 599 return {
592 likes.push(rate.Account.url) 600 data: rows,
593 } else if (rate.type === 'dislike') { 601 total: count
594 dislikes.push(rate.Account.url)
595 } 602 }
596 } 603 })
597
598 likesObject = activityPubCollection(likes)
599 dislikesObject = activityPubCollection(dislikes)
600 } 604 }
601 605
602 let sharesObject 606 private static createBaseVideosWhere () {
603 if (Array.isArray(this.VideoShares)) { 607 return {
604 const shares: string[] = [] 608 id: {
605 609 [Sequelize.Op.notIn]: VideoModel.sequelize.literal(
606 for (const videoShare of this.VideoShares) { 610 '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
607 const shareUrl = getAnnounceActivityPubUrl(this.url, videoShare.Account) 611 )
608 shares.push(shareUrl) 612 },
613 privacy: VideoPrivacy.PUBLIC
609 } 614 }
610
611 sharesObject = activityPubCollection(shares)
612 } 615 }
613 616
614 const url = [] 617 getOriginalFile () {
615 for (const file of this.VideoFiles) { 618 if (Array.isArray(this.VideoFiles) === false) return undefined
616 url.push({
617 type: 'Link',
618 mimeType: 'video/' + file.extname.replace('.', ''),
619 url: getVideoFileUrl(this, file, baseUrlHttp),
620 width: file.resolution,
621 size: file.size
622 })
623 619
624 url.push({ 620 // The original file is the file that have the higher resolution
625 type: 'Link', 621 return maxBy(this.VideoFiles, file => file.resolution)
626 mimeType: 'application/x-bittorrent',
627 url: getTorrentUrl(this, file, baseUrlHttp),
628 width: file.resolution
629 })
630
631 url.push({
632 type: 'Link',
633 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
634 url: generateMagnetUri(this, file, baseUrlHttp, baseUrlWs),
635 width: file.resolution
636 })
637 } 622 }
638 623
639 // Add video url too 624 getVideoFilename (videoFile: VideoFileModel) {
640 url.push({ 625 return this.uuid + '-' + videoFile.resolution + videoFile.extname
641 type: 'Link', 626 }
642 mimeType: 'text/html',
643 url: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
644 })
645 627
646 const videoObject: VideoTorrentObject = { 628 getThumbnailName () {
647 type: 'Video' as 'Video', 629 // We always have a copy of the thumbnail
648 id: this.url, 630 const extension = '.jpg'
649 name: this.name, 631 return this.uuid + extension
650 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
651 duration: 'PT' + this.duration + 'S',
652 uuid: this.uuid,
653 tag,
654 category,
655 licence,
656 language,
657 views: this.views,
658 nsfw: this.nsfw,
659 published: this.createdAt.toISOString(),
660 updated: this.updatedAt.toISOString(),
661 mediaType: 'text/markdown',
662 content: this.getTruncatedDescription(),
663 icon: {
664 type: 'Image',
665 url: getThumbnailUrl(this, baseUrlHttp),
666 mediaType: 'image/jpeg',
667 width: THUMBNAILS_SIZE.width,
668 height: THUMBNAILS_SIZE.height
669 },
670 url,
671 likes: likesObject,
672 dislikes: dislikesObject,
673 shares: sharesObject
674 } 632 }
675 633
676 return videoObject 634 getPreviewName () {
677} 635 const extension = '.jpg'
636 return this.uuid + extension
637 }
678 638
679getTruncatedDescription = function (this: VideoInstance) { 639 getTorrentFileName (videoFile: VideoFileModel) {
680 if (!this.description) return null 640 const extension = '.torrent'
641 return this.uuid + '-' + videoFile.resolution + extension
642 }
681 643
682 const options = { 644 isOwned () {
683 length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max 645 return this.remote === false
684 } 646 }
685 647
686 return truncate(this.description, options) 648 createPreview (videoFile: VideoFileModel) {
687} 649 const imageSize = PREVIEWS_SIZE.width + 'x' + PREVIEWS_SIZE.height
650
651 return generateImageFromVideoFile(
652 this.getVideoFilePath(videoFile),
653 CONFIG.STORAGE.PREVIEWS_DIR,
654 this.getPreviewName(),
655 imageSize
656 )
657 }
688 658
689optimizeOriginalVideofile = async function (this: VideoInstance) { 659 createThumbnail (videoFile: VideoFileModel) {
690 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR 660 const imageSize = THUMBNAILS_SIZE.width + 'x' + THUMBNAILS_SIZE.height
691 const newExtname = '.mp4'
692 const inputVideoFile = this.getOriginalFile()
693 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
694 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
695 661
696 const transcodeOptions = { 662 return generateImageFromVideoFile(
697 inputPath: videoInputPath, 663 this.getVideoFilePath(videoFile),
698 outputPath: videoOutputPath 664 CONFIG.STORAGE.THUMBNAILS_DIR,
665 this.getThumbnailName(),
666 imageSize
667 )
699 } 668 }
700 669
701 try { 670 getVideoFilePath (videoFile: VideoFileModel) {
702 // Could be very long! 671 return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
703 await transcode(transcodeOptions) 672 }
704 673
705 await unlinkPromise(videoInputPath) 674 createTorrentAndSetInfoHash = async function (videoFile: VideoFileModel) {
675 const options = {
676 announceList: [
677 [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
678 ],
679 urlList: [
680 CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
681 ]
682 }
706 683
707 // Important to do this before getVideoFilename() to take in account the new file extension 684 const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
708 inputVideoFile.set('extname', newExtname)
709 685
710 await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile)) 686 const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
711 const stats = await statPromise(this.getVideoFilePath(inputVideoFile)) 687 logger.info('Creating torrent %s.', filePath)
712 688
713 inputVideoFile.set('size', stats.size) 689 await writeFilePromise(filePath, torrent)
714 690
715 await this.createTorrentAndSetInfoHash(inputVideoFile) 691 const parsedTorrent = parseTorrent(torrent)
716 await inputVideoFile.save() 692 videoFile.infoHash = parsedTorrent.infoHash
693 }
717 694
718 } catch (err) { 695 getEmbedPath () {
719 // Auto destruction... 696 return '/videos/embed/' + this.uuid
720 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err)) 697 }
721 698
722 throw err 699 getThumbnailPath () {
700 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
723 } 701 }
724}
725 702
726transcodeOriginalVideofile = async function (this: VideoInstance, resolution: VideoResolution) { 703 getPreviewPath () {
727 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR 704 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
728 const extname = '.mp4' 705 }
729 706
730 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed 707 toFormattedJSON () {
731 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile())) 708 let serverHost
732 709
733 const newVideoFile = (Video['sequelize'].models.VideoFile as VideoFileModel).build({ 710 if (this.VideoChannel.Account.Server) {
734 resolution, 711 serverHost = this.VideoChannel.Account.Server.host
735 extname, 712 } else {
736 size: 0, 713 // It means it's our video
737 videoId: this.id 714 serverHost = CONFIG.WEBSERVER.HOST
738 }) 715 }
739 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
740 716
741 const transcodeOptions = { 717 return {
742 inputPath: videoInputPath, 718 id: this.id,
743 outputPath: videoOutputPath, 719 uuid: this.uuid,
744 resolution 720 name: this.name,
721 category: this.category,
722 categoryLabel: this.getCategoryLabel(),
723 licence: this.licence,
724 licenceLabel: this.getLicenceLabel(),
725 language: this.language,
726 languageLabel: this.getLanguageLabel(),
727 nsfw: this.nsfw,
728 description: this.getTruncatedDescription(),
729 serverHost,
730 isLocal: this.isOwned(),
731 accountName: this.VideoChannel.Account.name,
732 duration: this.duration,
733 views: this.views,
734 likes: this.likes,
735 dislikes: this.dislikes,
736 tags: map<TagModel, string>(this.Tags, 'name'),
737 thumbnailPath: this.getThumbnailPath(),
738 previewPath: this.getPreviewPath(),
739 embedPath: this.getEmbedPath(),
740 createdAt: this.createdAt,
741 updatedAt: this.updatedAt
742 }
745 } 743 }
746 744
747 await transcode(transcodeOptions) 745 toFormattedDetailsJSON () {
746 const formattedJson = this.toFormattedJSON()
748 747
749 const stats = await statPromise(videoOutputPath) 748 // Maybe our server is not up to date and there are new privacy settings since our version
749 let privacyLabel = VIDEO_PRIVACIES[this.privacy]
750 if (!privacyLabel) privacyLabel = 'Unknown'
750 751
751 newVideoFile.set('size', stats.size) 752 const detailsJson = {
753 privacyLabel,
754 privacy: this.privacy,
755 descriptionPath: this.getDescriptionPath(),
756 channel: this.VideoChannel.toFormattedJSON(),
757 account: this.VideoChannel.Account.toFormattedJSON(),
758 files: []
759 }
752 760
753 await this.createTorrentAndSetInfoHash(newVideoFile) 761 // Format and sort video files
762 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
763 detailsJson.files = this.VideoFiles
764 .map(videoFile => {
765 let resolutionLabel = videoFile.resolution + 'p'
766
767 return {
768 resolution: videoFile.resolution,
769 resolutionLabel,
770 magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
771 size: videoFile.size,
772 torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
773 fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp)
774 }
775 })
776 .sort((a, b) => {
777 if (a.resolution < b.resolution) return 1
778 if (a.resolution === b.resolution) return 0
779 return -1
780 })
781
782 return Object.assign(formattedJson, detailsJson)
783 }
754 784
755 await newVideoFile.save() 785 toActivityPubObject (): VideoTorrentObject {
786 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
787 if (!this.Tags) this.Tags = []
756 788
757 this.VideoFiles.push(newVideoFile) 789 const tag = this.Tags.map(t => ({
758} 790 type: 'Hashtag' as 'Hashtag',
791 name: t.name
792 }))
759 793
760getOriginalFileHeight = function (this: VideoInstance) { 794 let language
761 const originalFilePath = this.getVideoFilePath(this.getOriginalFile()) 795 if (this.language) {
796 language = {
797 identifier: this.language + '',
798 name: this.getLanguageLabel()
799 }
800 }
762 801
763 return getVideoFileHeight(originalFilePath) 802 let category
764} 803 if (this.category) {
804 category = {
805 identifier: this.category + '',
806 name: this.getCategoryLabel()
807 }
808 }
765 809
766getDescriptionPath = function (this: VideoInstance) { 810 let licence
767 return `/api/${API_VERSION}/videos/${this.uuid}/description` 811 if (this.licence) {
768} 812 licence = {
813 identifier: this.licence + '',
814 name: this.getLicenceLabel()
815 }
816 }
769 817
770getCategoryLabel = function (this: VideoInstance) { 818 let likesObject
771 let categoryLabel = VIDEO_CATEGORIES[this.category] 819 let dislikesObject
772 if (!categoryLabel) categoryLabel = 'Misc'
773 820
774 return categoryLabel 821 if (Array.isArray(this.AccountVideoRates)) {
775} 822 const likes: string[] = []
823 const dislikes: string[] = []
776 824
777getLicenceLabel = function (this: VideoInstance) { 825 for (const rate of this.AccountVideoRates) {
778 let licenceLabel = VIDEO_LICENCES[this.licence] 826 if (rate.type === 'like') {
779 if (!licenceLabel) licenceLabel = 'Unknown' 827 likes.push(rate.Account.url)
828 } else if (rate.type === 'dislike') {
829 dislikes.push(rate.Account.url)
830 }
831 }
780 832
781 return licenceLabel 833 likesObject = activityPubCollection(likes)
782} 834 dislikesObject = activityPubCollection(dislikes)
835 }
783 836
784getLanguageLabel = function (this: VideoInstance) { 837 let sharesObject
785 let languageLabel = VIDEO_LANGUAGES[this.language] 838 if (Array.isArray(this.VideoShares)) {
786 if (!languageLabel) languageLabel = 'Unknown' 839 const shares: string[] = []
787 840
788 return languageLabel 841 for (const videoShare of this.VideoShares) {
789} 842 const shareUrl = getAnnounceActivityPubUrl(this.url, videoShare.Account)
843 shares.push(shareUrl)
844 }
790 845
791removeThumbnail = function (this: VideoInstance) { 846 sharesObject = activityPubCollection(shares)
792 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName()) 847 }
793 return unlinkPromise(thumbnailPath)
794}
795 848
796removePreview = function (this: VideoInstance) { 849 const url = []
797 // Same name than video thumbnail 850 for (const file of this.VideoFiles) {
798 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName()) 851 url.push({
799} 852 type: 'Link',
853 mimeType: 'video/' + file.extname.replace('.', ''),
854 url: this.getVideoFileUrl(file, baseUrlHttp),
855 width: file.resolution,
856 size: file.size
857 })
858
859 url.push({
860 type: 'Link',
861 mimeType: 'application/x-bittorrent',
862 url: this.getTorrentUrl(file, baseUrlHttp),
863 width: file.resolution
864 })
865
866 url.push({
867 type: 'Link',
868 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet',
869 url: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
870 width: file.resolution
871 })
872 }
800 873
801removeFile = function (this: VideoInstance, videoFile: VideoFileInstance) { 874 // Add video url too
802 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile)) 875 url.push({
803 return unlinkPromise(filePath) 876 type: 'Link',
804} 877 mimeType: 'text/html',
878 url: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
879 })
805 880
806removeTorrent = function (this: VideoInstance, videoFile: VideoFileInstance) { 881 return {
807 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile)) 882 type: 'Video' as 'Video',
808 return unlinkPromise(torrentPath) 883 id: this.url,
809} 884 name: this.name,
885 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
886 duration: 'PT' + this.duration + 'S',
887 uuid: this.uuid,
888 tag,
889 category,
890 licence,
891 language,
892 views: this.views,
893 nsfw: this.nsfw,
894 published: this.createdAt.toISOString(),
895 updated: this.updatedAt.toISOString(),
896 mediaType: 'text/markdown',
897 content: this.getTruncatedDescription(),
898 icon: {
899 type: 'Image',
900 url: this.getThumbnailUrl(baseUrlHttp),
901 mediaType: 'image/jpeg',
902 width: THUMBNAILS_SIZE.width,
903 height: THUMBNAILS_SIZE.height
904 },
905 url,
906 likes: likesObject,
907 dislikes: dislikesObject,
908 shares: sharesObject
909 }
910 }
911
912 getTruncatedDescription () {
913 if (!this.description) return null
810 914
811// ------------------------------ STATICS ------------------------------ 915 const options = {
916 length: CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
917 }
812 918
813list = function () { 919 return truncate(this.description, options)
814 const query = {
815 include: [ Video['sequelize'].models.VideoFile ]
816 } 920 }
817 921
818 return Video.findAll(query) 922 optimizeOriginalVideofile = async function () {
819} 923 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
924 const newExtname = '.mp4'
925 const inputVideoFile = this.getOriginalFile()
926 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
927 const videoOutputPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
820 928
821listAllAndSharedByAccountForOutbox = function (accountId: number, start: number, count: number) { 929 const transcodeOptions = {
822 function getRawQuery (select: string) { 930 inputPath: videoInputPath,
823 const queryVideo = 'SELECT ' + select + ' FROM "Videos" AS "Video" ' + 931 outputPath: videoOutputPath
824 'INNER JOIN "VideoChannels" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' + 932 }
825 'WHERE "VideoChannel"."accountId" = ' + accountId
826 const queryVideoShare = 'SELECT ' + select + ' FROM "VideoShares" AS "VideoShare" ' +
827 'INNER JOIN "Videos" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
828 'WHERE "VideoShare"."accountId" = ' + accountId
829 933
830 let rawQuery = `(${queryVideo}) UNION (${queryVideoShare})` 934 try {
935 // Could be very long!
936 await transcode(transcodeOptions)
831 937
832 return rawQuery 938 await unlinkPromise(videoInputPath)
833 }
834 939
835 const rawQuery = getRawQuery('"Video"."id"') 940 // Important to do this before getVideoFilename() to take in account the new file extension
836 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"') 941 inputVideoFile.set('extname', newExtname)
837 942
838 const query = { 943 await renamePromise(videoOutputPath, this.getVideoFilePath(inputVideoFile))
839 distinct: true, 944 const stats = await statPromise(this.getVideoFilePath(inputVideoFile))
840 offset: start,
841 limit: count,
842 order: [ getSort('createdAt'), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
843 where: {
844 id: {
845 [Sequelize.Op.in]: Sequelize.literal('(' + rawQuery + ')')
846 }
847 },
848 include: [
849 {
850 model: Video['sequelize'].models.VideoShare,
851 required: false,
852 where: {
853 [Sequelize.Op.and]: [
854 {
855 id: {
856 [Sequelize.Op.not]: null
857 }
858 },
859 {
860 accountId
861 }
862 ]
863 },
864 include: [ Video['sequelize'].models.Account ]
865 },
866 {
867 model: Video['sequelize'].models.VideoChannel,
868 required: true,
869 include: [
870 {
871 model: Video['sequelize'].models.Account,
872 required: true
873 }
874 ]
875 },
876 {
877 model: Video['sequelize'].models.AccountVideoRate,
878 include: [ Video['sequelize'].models.Account ]
879 },
880 Video['sequelize'].models.VideoFile,
881 Video['sequelize'].models.Tag
882 ]
883 }
884 945
885 return Bluebird.all([ 946 inputVideoFile.set('size', stats.size)
886 Video.findAll(query),
887 Video['sequelize'].query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
888 ]).then(([ rows, totals ]) => {
889 // totals: totalVideos + totalVideoShares
890 let totalVideos = 0
891 let totalVideoShares = 0
892 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
893 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
894
895 const total = totalVideos + totalVideoShares
896 return {
897 data: rows,
898 total: total
899 }
900 })
901}
902 947
903listUserVideosForApi = function (userId: number, start: number, count: number, sort: string) { 948 await this.createTorrentAndSetInfoHash(inputVideoFile)
904 const query = { 949 await inputVideoFile.save()
905 distinct: true,
906 offset: start,
907 limit: count,
908 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
909 include: [
910 {
911 model: Video['sequelize'].models.VideoChannel,
912 required: true,
913 include: [
914 {
915 model: Video['sequelize'].models.Account,
916 where: {
917 userId
918 },
919 required: true
920 }
921 ]
922 },
923 Video['sequelize'].models.Tag
924 ]
925 }
926 950
927 return Video.findAndCountAll(query).then(({ rows, count }) => { 951 } catch (err) {
928 return { 952 // Auto destruction...
929 data: rows, 953 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', err))
930 total: count
931 }
932 })
933}
934 954
935listForApi = function (start: number, count: number, sort: string) { 955 throw err
936 const query = { 956 }
937 distinct: true,
938 offset: start,
939 limit: count,
940 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
941 include: [
942 {
943 model: Video['sequelize'].models.VideoChannel,
944 required: true,
945 include: [
946 {
947 model: Video['sequelize'].models.Account,
948 required: true,
949 include: [
950 {
951 model: Video['sequelize'].models.Server,
952 required: false
953 }
954 ]
955 }
956 ]
957 },
958 Video['sequelize'].models.Tag
959 ],
960 where: createBaseVideosWhere()
961 } 957 }
962 958
963 return Video.findAndCountAll(query).then(({ rows, count }) => { 959 transcodeOriginalVideofile = async function (resolution: VideoResolution) {
964 return { 960 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
965 data: rows, 961 const extname = '.mp4'
966 total: count
967 }
968 })
969}
970 962
971load = function (id: number) { 963 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
972 return Video.findById(id) 964 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
973}
974 965
975loadByUUID = function (uuid: string, t?: Sequelize.Transaction) { 966 const newVideoFile = new VideoFileModel({
976 const query: Sequelize.FindOptions<VideoAttributes> = { 967 resolution,
977 where: { 968 extname,
978 uuid 969 size: 0,
979 }, 970 videoId: this.id
980 include: [ Video['sequelize'].models.VideoFile ] 971 })
981 } 972 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
982 973
983 if (t !== undefined) query.transaction = t 974 const transcodeOptions = {
975 inputPath: videoInputPath,
976 outputPath: videoOutputPath,
977 resolution
978 }
984 979
985 return Video.findOne(query) 980 await transcode(transcodeOptions)
986}
987 981
988loadByUrlAndPopulateAccount = function (url: string, t?: Sequelize.Transaction) { 982 const stats = await statPromise(videoOutputPath)
989 const query: Sequelize.FindOptions<VideoAttributes> = {
990 where: {
991 url
992 },
993 include: [
994 Video['sequelize'].models.VideoFile,
995 {
996 model: Video['sequelize'].models.VideoChannel,
997 include: [ Video['sequelize'].models.Account ]
998 }
999 ]
1000 }
1001 983
1002 if (t !== undefined) query.transaction = t 984 newVideoFile.set('size', stats.size)
1003 985
1004 return Video.findOne(query) 986 await this.createTorrentAndSetInfoHash(newVideoFile)
1005}
1006 987
1007loadByUUIDOrURL = function (uuid: string, url: string, t?: Sequelize.Transaction) { 988 await newVideoFile.save()
1008 const query: Sequelize.FindOptions<VideoAttributes> = { 989
1009 where: { 990 this.VideoFiles.push(newVideoFile)
1010 [Sequelize.Op.or]: [
1011 { uuid },
1012 { url }
1013 ]
1014 },
1015 include: [ Video['sequelize'].models.VideoFile ]
1016 } 991 }
1017 992
1018 if (t !== undefined) query.transaction = t 993 getOriginalFileHeight () {
994 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1019 995
1020 return Video.findOne(query) 996 return getVideoFileHeight(originalFilePath)
1021} 997 }
1022 998
1023loadAndPopulateAccountAndServerAndTags = function (id: number) { 999 getDescriptionPath () {
1024 const options = { 1000 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1025 order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
1026 include: [
1027 {
1028 model: Video['sequelize'].models.VideoChannel,
1029 include: [
1030 {
1031 model: Video['sequelize'].models.Account,
1032 include: [ { model: Video['sequelize'].models.Server, required: false } ]
1033 }
1034 ]
1035 },
1036 {
1037 model: Video['sequelize'].models.AccountVideoRate,
1038 include: [ Video['sequelize'].models.Account ]
1039 },
1040 {
1041 model: Video['sequelize'].models.VideoShare,
1042 include: [ Video['sequelize'].models.Account ]
1043 },
1044 Video['sequelize'].models.Tag,
1045 Video['sequelize'].models.VideoFile
1046 ]
1047 } 1001 }
1048 1002
1049 return Video.findById(id, options) 1003 getCategoryLabel () {
1050} 1004 let categoryLabel = VIDEO_CATEGORIES[this.category]
1005 if (!categoryLabel) categoryLabel = 'Misc'
1051 1006
1052loadByUUIDAndPopulateAccountAndServerAndTags = function (uuid: string) { 1007 return categoryLabel
1053 const options = {
1054 order: [ [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ],
1055 where: {
1056 uuid
1057 },
1058 include: [
1059 {
1060 model: Video['sequelize'].models.VideoChannel,
1061 include: [
1062 {
1063 model: Video['sequelize'].models.Account,
1064 include: [ { model: Video['sequelize'].models.Server, required: false } ]
1065 }
1066 ]
1067 },
1068 {
1069 model: Video['sequelize'].models.AccountVideoRate,
1070 include: [ Video['sequelize'].models.Account ]
1071 },
1072 {
1073 model: Video['sequelize'].models.VideoShare,
1074 include: [ Video['sequelize'].models.Account ]
1075 },
1076 Video['sequelize'].models.Tag,
1077 Video['sequelize'].models.VideoFile
1078 ]
1079 } 1008 }
1080 1009
1081 return Video.findOne(options) 1010 getLicenceLabel () {
1082} 1011 let licenceLabel = VIDEO_LICENCES[this.licence]
1012 if (!licenceLabel) licenceLabel = 'Unknown'
1083 1013
1084searchAndPopulateAccountAndServerAndTags = function (value: string, start: number, count: number, sort: string) { 1014 return licenceLabel
1085 const serverInclude: Sequelize.IncludeOptions = {
1086 model: Video['sequelize'].models.Server,
1087 required: false
1088 } 1015 }
1089 1016
1090 const accountInclude: Sequelize.IncludeOptions = { 1017 getLanguageLabel () {
1091 model: Video['sequelize'].models.Account, 1018 let languageLabel = VIDEO_LANGUAGES[this.language]
1092 include: [ serverInclude ] 1019 if (!languageLabel) languageLabel = 'Unknown'
1020
1021 return languageLabel
1093 } 1022 }
1094 1023
1095 const videoChannelInclude: Sequelize.IncludeOptions = { 1024 removeThumbnail () {
1096 model: Video['sequelize'].models.VideoChannel, 1025 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
1097 include: [ accountInclude ], 1026 return unlinkPromise(thumbnailPath)
1098 required: true
1099 } 1027 }
1100 1028
1101 const tagInclude: Sequelize.IncludeOptions = { 1029 removePreview () {
1102 model: Video['sequelize'].models.Tag 1030 // Same name than video thumbnail
1031 return unlinkPromise(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
1103 } 1032 }
1104 1033
1105 const query: Sequelize.FindOptions<VideoAttributes> = { 1034 removeFile (videoFile: VideoFileModel) {
1106 distinct: true, 1035 const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
1107 where: createBaseVideosWhere(), 1036 return unlinkPromise(filePath)
1108 offset: start,
1109 limit: count,
1110 order: [ getSort(sort), [ Video['sequelize'].models.Tag, 'name', 'ASC' ] ]
1111 } 1037 }
1112 1038
1113 // TODO: search on tags too 1039 removeTorrent (videoFile: VideoFileModel) {
1114 // const escapedValue = Video['sequelize'].escape('%' + value + '%') 1040 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1115 // query.where['id'][Sequelize.Op.in] = Video['sequelize'].literal( 1041 return unlinkPromise(torrentPath)
1116 // `(SELECT "VideoTags"."videoId"
1117 // FROM "Tags"
1118 // INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId"
1119 // WHERE name ILIKE ${escapedValue}
1120 // )`
1121 // )
1122
1123 // TODO: search on account too
1124 // accountInclude.where = {
1125 // name: {
1126 // [Sequelize.Op.iLike]: '%' + value + '%'
1127 // }
1128 // }
1129 query.where['name'] = {
1130 [Sequelize.Op.iLike]: '%' + value + '%'
1131 } 1042 }
1132 1043
1133 query.include = [ 1044 private getBaseUrls () {
1134 videoChannelInclude, tagInclude 1045 let baseUrlHttp
1135 ] 1046 let baseUrlWs
1136 1047
1137 return Video.findAndCountAll(query).then(({ rows, count }) => { 1048 if (this.isOwned()) {
1138 return { 1049 baseUrlHttp = CONFIG.WEBSERVER.URL
1139 data: rows, 1050 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1140 total: count 1051 } else {
1052 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Server.host
1053 baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Server.host
1141 } 1054 }
1142 })
1143}
1144
1145// ---------------------------------------------------------------------------
1146 1055
1147function createBaseVideosWhere () { 1056 return { baseUrlHttp, baseUrlWs }
1148 return {
1149 id: {
1150 [Sequelize.Op.notIn]: Video['sequelize'].literal(
1151 '(SELECT "BlacklistedVideos"."videoId" FROM "BlacklistedVideos")'
1152 )
1153 },
1154 privacy: VideoPrivacy.PUBLIC
1155 } 1057 }
1156}
1157 1058
1158function getBaseUrls (video: VideoInstance) { 1059 private getThumbnailUrl (baseUrlHttp: string) {
1159 let baseUrlHttp 1060 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1160 let baseUrlWs
1161
1162 if (video.isOwned()) {
1163 baseUrlHttp = CONFIG.WEBSERVER.URL
1164 baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
1165 } else {
1166 baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + video.VideoChannel.Account.Server.host
1167 baseUrlWs = REMOTE_SCHEME.WS + '://' + video.VideoChannel.Account.Server.host
1168 } 1061 }
1169 1062
1170 return { baseUrlHttp, baseUrlWs } 1063 private getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1171} 1064 return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1172 1065 }
1173function getThumbnailUrl (video: VideoInstance, baseUrlHttp: string) {
1174 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + video.getThumbnailName()
1175}
1176 1066
1177function getTorrentUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) { 1067 private getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1178 return baseUrlHttp + STATIC_PATHS.TORRENTS + video.getTorrentFileName(videoFile) 1068 return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
1179} 1069 }
1180 1070
1181function getVideoFileUrl (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string) { 1071 private generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1182 return baseUrlHttp + STATIC_PATHS.WEBSEED + video.getVideoFilename(videoFile) 1072 const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1183} 1073 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1074 const urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1075
1076 const magnetHash = {
1077 xs,
1078 announce,
1079 urlList,
1080 infoHash: videoFile.infoHash,
1081 name: this.name
1082 }
1184 1083
1185function generateMagnetUri (video: VideoInstance, videoFile: VideoFileInstance, baseUrlHttp: string, baseUrlWs: string) { 1084 return magnetUtil.encode(magnetHash)
1186 const xs = getTorrentUrl(video, videoFile, baseUrlHttp)
1187 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1188 const urlList = [ getVideoFileUrl(video, videoFile, baseUrlHttp) ]
1189
1190 const magnetHash = {
1191 xs,
1192 announce,
1193 urlList,
1194 infoHash: videoFile.infoHash,
1195 name: video.name
1196 } 1085 }
1197
1198 return magnetUtil.encode(magnetHash)
1199} 1086}