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