diff options
author | Chocobozzz <me@florianbigard.com> | 2019-01-29 08:37:25 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2019-02-11 09:13:02 +0100 |
commit | 092092969633bbcf6d4891a083ea497a7d5c3154 (patch) | |
tree | 69e82fe4f60c444cca216830e96afe143a9dac71 /server/models | |
parent | 4348a27d252a3349bafa7ef4859c0e2cf060c255 (diff) | |
download | PeerTube-092092969633bbcf6d4891a083ea497a7d5c3154.tar.gz PeerTube-092092969633bbcf6d4891a083ea497a7d5c3154.tar.zst PeerTube-092092969633bbcf6d4891a083ea497a7d5c3154.zip |
Add hls support on server
Diffstat (limited to 'server/models')
-rw-r--r-- | server/models/redundancy/video-redundancy.ts | 139 | ||||
-rw-r--r-- | server/models/video/video-file.ts | 6 | ||||
-rw-r--r-- | server/models/video/video-format-utils.ts | 61 | ||||
-rw-r--r-- | server/models/video/video-streaming-playlist.ts | 154 | ||||
-rw-r--r-- | server/models/video/video.ts | 179 |
5 files changed, 470 insertions, 69 deletions
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts index 8f2ef2d9a..b722bed14 100644 --- a/server/models/redundancy/video-redundancy.ts +++ b/server/models/redundancy/video-redundancy.ts | |||
@@ -28,6 +28,7 @@ import { sample } from 'lodash' | |||
28 | import { isTestInstance } from '../../helpers/core-utils' | 28 | import { isTestInstance } from '../../helpers/core-utils' |
29 | import * as Bluebird from 'bluebird' | 29 | import * as Bluebird from 'bluebird' |
30 | import * as Sequelize from 'sequelize' | 30 | import * as Sequelize from 'sequelize' |
31 | import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist' | ||
31 | 32 | ||
32 | export enum ScopeNames { | 33 | export enum ScopeNames { |
33 | WITH_VIDEO = 'WITH_VIDEO' | 34 | WITH_VIDEO = 'WITH_VIDEO' |
@@ -38,7 +39,17 @@ export enum ScopeNames { | |||
38 | include: [ | 39 | include: [ |
39 | { | 40 | { |
40 | model: () => VideoFileModel, | 41 | model: () => VideoFileModel, |
41 | required: true, | 42 | required: false, |
43 | include: [ | ||
44 | { | ||
45 | model: () => VideoModel, | ||
46 | required: true | ||
47 | } | ||
48 | ] | ||
49 | }, | ||
50 | { | ||
51 | model: () => VideoStreamingPlaylistModel, | ||
52 | required: false, | ||
42 | include: [ | 53 | include: [ |
43 | { | 54 | { |
44 | model: () => VideoModel, | 55 | model: () => VideoModel, |
@@ -97,12 +108,24 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
97 | 108 | ||
98 | @BelongsTo(() => VideoFileModel, { | 109 | @BelongsTo(() => VideoFileModel, { |
99 | foreignKey: { | 110 | foreignKey: { |
100 | allowNull: false | 111 | allowNull: true |
101 | }, | 112 | }, |
102 | onDelete: 'cascade' | 113 | onDelete: 'cascade' |
103 | }) | 114 | }) |
104 | VideoFile: VideoFileModel | 115 | VideoFile: VideoFileModel |
105 | 116 | ||
117 | @ForeignKey(() => VideoStreamingPlaylistModel) | ||
118 | @Column | ||
119 | videoStreamingPlaylistId: number | ||
120 | |||
121 | @BelongsTo(() => VideoStreamingPlaylistModel, { | ||
122 | foreignKey: { | ||
123 | allowNull: true | ||
124 | }, | ||
125 | onDelete: 'cascade' | ||
126 | }) | ||
127 | VideoStreamingPlaylist: VideoStreamingPlaylistModel | ||
128 | |||
106 | @ForeignKey(() => ActorModel) | 129 | @ForeignKey(() => ActorModel) |
107 | @Column | 130 | @Column |
108 | actorId: number | 131 | actorId: number |
@@ -119,13 +142,25 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
119 | static async removeFile (instance: VideoRedundancyModel) { | 142 | static async removeFile (instance: VideoRedundancyModel) { |
120 | if (!instance.isOwned()) return | 143 | if (!instance.isOwned()) return |
121 | 144 | ||
122 | const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId) | 145 | if (instance.videoFileId) { |
146 | const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId) | ||
123 | 147 | ||
124 | const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}` | 148 | const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}` |
125 | logger.info('Removing duplicated video file %s.', logIdentifier) | 149 | logger.info('Removing duplicated video file %s.', logIdentifier) |
126 | 150 | ||
127 | videoFile.Video.removeFile(videoFile, true) | 151 | videoFile.Video.removeFile(videoFile, true) |
128 | .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err })) | 152 | .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err })) |
153 | } | ||
154 | |||
155 | if (instance.videoStreamingPlaylistId) { | ||
156 | const videoStreamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(instance.videoStreamingPlaylistId) | ||
157 | |||
158 | const videoUUID = videoStreamingPlaylist.Video.uuid | ||
159 | logger.info('Removing duplicated video streaming playlist %s.', videoUUID) | ||
160 | |||
161 | videoStreamingPlaylist.Video.removeStreamingPlaylist(true) | ||
162 | .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err })) | ||
163 | } | ||
129 | 164 | ||
130 | return undefined | 165 | return undefined |
131 | } | 166 | } |
@@ -143,6 +178,19 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
143 | return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query) | 178 | return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query) |
144 | } | 179 | } |
145 | 180 | ||
181 | static async loadLocalByStreamingPlaylistId (videoStreamingPlaylistId: number) { | ||
182 | const actor = await getServerActor() | ||
183 | |||
184 | const query = { | ||
185 | where: { | ||
186 | actorId: actor.id, | ||
187 | videoStreamingPlaylistId | ||
188 | } | ||
189 | } | ||
190 | |||
191 | return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query) | ||
192 | } | ||
193 | |||
146 | static loadByUrl (url: string, transaction?: Sequelize.Transaction) { | 194 | static loadByUrl (url: string, transaction?: Sequelize.Transaction) { |
147 | const query = { | 195 | const query = { |
148 | where: { | 196 | where: { |
@@ -191,7 +239,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
191 | const ids = rows.map(r => r.id) | 239 | const ids = rows.map(r => r.id) |
192 | const id = sample(ids) | 240 | const id = sample(ids) |
193 | 241 | ||
194 | return VideoModel.loadWithFile(id, undefined, !isTestInstance()) | 242 | return VideoModel.loadWithFiles(id, undefined, !isTestInstance()) |
195 | } | 243 | } |
196 | 244 | ||
197 | static async findMostViewToDuplicate (randomizedFactor: number) { | 245 | static async findMostViewToDuplicate (randomizedFactor: number) { |
@@ -333,40 +381,44 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
333 | 381 | ||
334 | static async listLocalOfServer (serverId: number) { | 382 | static async listLocalOfServer (serverId: number) { |
335 | const actor = await getServerActor() | 383 | const actor = await getServerActor() |
336 | 384 | const buildVideoInclude = () => ({ | |
337 | const query = { | 385 | model: VideoModel, |
338 | where: { | 386 | required: true, |
339 | actorId: actor.id | ||
340 | }, | ||
341 | include: [ | 387 | include: [ |
342 | { | 388 | { |
343 | model: VideoFileModel, | 389 | attributes: [], |
390 | model: VideoChannelModel.unscoped(), | ||
344 | required: true, | 391 | required: true, |
345 | include: [ | 392 | include: [ |
346 | { | 393 | { |
347 | model: VideoModel, | 394 | attributes: [], |
395 | model: ActorModel.unscoped(), | ||
348 | required: true, | 396 | required: true, |
349 | include: [ | 397 | where: { |
350 | { | 398 | serverId |
351 | attributes: [], | 399 | } |
352 | model: VideoChannelModel.unscoped(), | ||
353 | required: true, | ||
354 | include: [ | ||
355 | { | ||
356 | attributes: [], | ||
357 | model: ActorModel.unscoped(), | ||
358 | required: true, | ||
359 | where: { | ||
360 | serverId | ||
361 | } | ||
362 | } | ||
363 | ] | ||
364 | } | ||
365 | ] | ||
366 | } | 400 | } |
367 | ] | 401 | ] |
368 | } | 402 | } |
369 | ] | 403 | ] |
404 | }) | ||
405 | |||
406 | const query = { | ||
407 | where: { | ||
408 | actorId: actor.id | ||
409 | }, | ||
410 | include: [ | ||
411 | { | ||
412 | model: VideoFileModel, | ||
413 | required: false, | ||
414 | include: [ buildVideoInclude() ] | ||
415 | }, | ||
416 | { | ||
417 | model: VideoStreamingPlaylistModel, | ||
418 | required: false, | ||
419 | include: [ buildVideoInclude() ] | ||
420 | } | ||
421 | ] | ||
370 | } | 422 | } |
371 | 423 | ||
372 | return VideoRedundancyModel.findAll(query) | 424 | return VideoRedundancyModel.findAll(query) |
@@ -403,11 +455,32 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
403 | })) | 455 | })) |
404 | } | 456 | } |
405 | 457 | ||
458 | getVideo () { | ||
459 | if (this.VideoFile) return this.VideoFile.Video | ||
460 | |||
461 | return this.VideoStreamingPlaylist.Video | ||
462 | } | ||
463 | |||
406 | isOwned () { | 464 | isOwned () { |
407 | return !!this.strategy | 465 | return !!this.strategy |
408 | } | 466 | } |
409 | 467 | ||
410 | toActivityPubObject (): CacheFileObject { | 468 | toActivityPubObject (): CacheFileObject { |
469 | if (this.VideoStreamingPlaylist) { | ||
470 | return { | ||
471 | id: this.url, | ||
472 | type: 'CacheFile' as 'CacheFile', | ||
473 | object: this.VideoStreamingPlaylist.Video.url, | ||
474 | expires: this.expiresOn.toISOString(), | ||
475 | url: { | ||
476 | type: 'Link', | ||
477 | mimeType: 'application/x-mpegURL', | ||
478 | mediaType: 'application/x-mpegURL', | ||
479 | href: this.fileUrl | ||
480 | } | ||
481 | } | ||
482 | } | ||
483 | |||
411 | return { | 484 | return { |
412 | id: this.url, | 485 | id: this.url, |
413 | type: 'CacheFile' as 'CacheFile', | 486 | type: 'CacheFile' as 'CacheFile', |
@@ -431,7 +504,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> { | |||
431 | 504 | ||
432 | const notIn = Sequelize.literal( | 505 | const notIn = Sequelize.literal( |
433 | '(' + | 506 | '(' + |
434 | `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id}` + | 507 | `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id} AND "videoFileId" IS NOT NULL` + |
435 | ')' | 508 | ')' |
436 | ) | 509 | ) |
437 | 510 | ||
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts index 1f1b76c1e..7d1e371b9 100644 --- a/server/models/video/video-file.ts +++ b/server/models/video/video-file.ts | |||
@@ -62,7 +62,7 @@ export class VideoFileModel extends Model<VideoFileModel> { | |||
62 | extname: string | 62 | extname: string |
63 | 63 | ||
64 | @AllowNull(false) | 64 | @AllowNull(false) |
65 | @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash')) | 65 | @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash')) |
66 | @Column | 66 | @Column |
67 | infoHash: string | 67 | infoHash: string |
68 | 68 | ||
@@ -86,14 +86,14 @@ export class VideoFileModel extends Model<VideoFileModel> { | |||
86 | 86 | ||
87 | @HasMany(() => VideoRedundancyModel, { | 87 | @HasMany(() => VideoRedundancyModel, { |
88 | foreignKey: { | 88 | foreignKey: { |
89 | allowNull: false | 89 | allowNull: true |
90 | }, | 90 | }, |
91 | onDelete: 'CASCADE', | 91 | onDelete: 'CASCADE', |
92 | hooks: true | 92 | hooks: true |
93 | }) | 93 | }) |
94 | RedundancyVideos: VideoRedundancyModel[] | 94 | RedundancyVideos: VideoRedundancyModel[] |
95 | 95 | ||
96 | static isInfohashExists (infoHash: string) { | 96 | static doesInfohashExist (infoHash: string) { |
97 | const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' | 97 | const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' |
98 | const options = { | 98 | const options = { |
99 | type: Sequelize.QueryTypes.SELECT, | 99 | type: Sequelize.QueryTypes.SELECT, |
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index de0747f22..e49dbee30 100644 --- a/server/models/video/video-format-utils.ts +++ b/server/models/video/video-format-utils.ts | |||
@@ -1,7 +1,12 @@ | |||
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 | ActivityPlaylistInfohashesObject, | ||
6 | ActivityPlaylistSegmentHashesObject, | ||
7 | ActivityUrlObject, | ||
8 | VideoTorrentObject | ||
9 | } from '../../../shared/models/activitypub/objects' | ||
5 | import { CONFIG, MIMETYPES, THUMBNAILS_SIZE } from '../../initializers' | 10 | import { CONFIG, MIMETYPES, THUMBNAILS_SIZE } from '../../initializers' |
6 | import { VideoCaptionModel } from './video-caption' | 11 | import { VideoCaptionModel } from './video-caption' |
7 | import { | 12 | import { |
@@ -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 |
@@ -120,7 +127,12 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails { | |||
120 | } | 127 | } |
121 | }) | 128 | }) |
122 | 129 | ||
130 | const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() | ||
131 | |||
123 | const tags = video.Tags ? video.Tags.map(t => t.name) : [] | 132 | const tags = video.Tags ? video.Tags.map(t => t.name) : [] |
133 | |||
134 | const streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists) | ||
135 | |||
124 | const detailsJson = { | 136 | const detailsJson = { |
125 | support: video.support, | 137 | support: video.support, |
126 | descriptionPath: video.getDescriptionAPIPath(), | 138 | descriptionPath: video.getDescriptionAPIPath(), |
@@ -133,7 +145,11 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails { | |||
133 | id: video.state, | 145 | id: video.state, |
134 | label: VideoModel.getStateLabel(video.state) | 146 | label: VideoModel.getStateLabel(video.state) |
135 | }, | 147 | }, |
136 | files: [] | 148 | |
149 | trackerUrls: video.getTrackerUrls(baseUrlHttp, baseUrlWs), | ||
150 | |||
151 | files: [], | ||
152 | streamingPlaylists | ||
137 | } | 153 | } |
138 | 154 | ||
139 | // Format and sort video files | 155 | // Format and sort video files |
@@ -142,6 +158,25 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails { | |||
142 | return Object.assign(formattedJson, detailsJson) | 158 | return Object.assign(formattedJson, detailsJson) |
143 | } | 159 | } |
144 | 160 | ||
161 | function streamingPlaylistsModelToFormattedJSON (video: VideoModel, playlists: VideoStreamingPlaylistModel[]): VideoStreamingPlaylist[] { | ||
162 | if (isArray(playlists) === false) return [] | ||
163 | |||
164 | return playlists | ||
165 | .map(playlist => { | ||
166 | const redundancies = isArray(playlist.RedundancyVideos) | ||
167 | ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl })) | ||
168 | : [] | ||
169 | |||
170 | return { | ||
171 | id: playlist.id, | ||
172 | type: playlist.type, | ||
173 | playlistUrl: playlist.playlistUrl, | ||
174 | segmentsSha256Url: playlist.segmentsSha256Url, | ||
175 | redundancies | ||
176 | } as VideoStreamingPlaylist | ||
177 | }) | ||
178 | } | ||
179 | |||
145 | function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] { | 180 | function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] { |
146 | const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() | 181 | const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() |
147 | 182 | ||
@@ -232,6 +267,28 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject { | |||
232 | }) | 267 | }) |
233 | } | 268 | } |
234 | 269 | ||
270 | for (const playlist of (video.VideoStreamingPlaylists || [])) { | ||
271 | let tag: (ActivityPlaylistSegmentHashesObject | ActivityPlaylistInfohashesObject)[] | ||
272 | |||
273 | tag = playlist.p2pMediaLoaderInfohashes | ||
274 | .map(i => ({ type: 'Infohash' as 'Infohash', name: i })) | ||
275 | tag.push({ | ||
276 | type: 'Link', | ||
277 | name: 'sha256', | ||
278 | mimeType: 'application/json' as 'application/json', | ||
279 | mediaType: 'application/json' as 'application/json', | ||
280 | href: playlist.segmentsSha256Url | ||
281 | }) | ||
282 | |||
283 | url.push({ | ||
284 | type: 'Link', | ||
285 | mimeType: 'application/x-mpegURL' as 'application/x-mpegURL', | ||
286 | mediaType: 'application/x-mpegURL' as 'application/x-mpegURL', | ||
287 | href: playlist.playlistUrl, | ||
288 | tag | ||
289 | }) | ||
290 | } | ||
291 | |||
235 | // Add video url too | 292 | // Add video url too |
236 | url.push({ | 293 | url.push({ |
237 | type: 'Link', | 294 | type: 'Link', |
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts new file mode 100644 index 000000000..bce537781 --- /dev/null +++ b/server/models/video/video-streaming-playlist.ts | |||
@@ -0,0 +1,154 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos' | ||
3 | import { throwIfNotValid } from '../utils' | ||
4 | import { VideoModel } from './video' | ||
5 | import * as Sequelize from 'sequelize' | ||
6 | import { VideoRedundancyModel } from '../redundancy/video-redundancy' | ||
7 | import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' | ||
8 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
9 | import { CONSTRAINTS_FIELDS, STATIC_PATHS } from '../../initializers' | ||
10 | import { VideoFileModel } from './video-file' | ||
11 | import { join } from 'path' | ||
12 | import { sha1 } from '../../helpers/core-utils' | ||
13 | import { isArrayOf } from '../../helpers/custom-validators/misc' | ||
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 | @Is('VideoStreamingSegmentsSha256Url', value => throwIfNotValid(value, isActivityPubUrlValid, 'segments sha256 url')) | ||
54 | @Column | ||
55 | segmentsSha256Url: string | ||
56 | |||
57 | @ForeignKey(() => VideoModel) | ||
58 | @Column | ||
59 | videoId: number | ||
60 | |||
61 | @BelongsTo(() => VideoModel, { | ||
62 | foreignKey: { | ||
63 | allowNull: false | ||
64 | }, | ||
65 | onDelete: 'CASCADE' | ||
66 | }) | ||
67 | Video: VideoModel | ||
68 | |||
69 | @HasMany(() => VideoRedundancyModel, { | ||
70 | foreignKey: { | ||
71 | allowNull: false | ||
72 | }, | ||
73 | onDelete: 'CASCADE', | ||
74 | hooks: true | ||
75 | }) | ||
76 | RedundancyVideos: VideoRedundancyModel[] | ||
77 | |||
78 | static doesInfohashExist (infoHash: string) { | ||
79 | const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1' | ||
80 | const options = { | ||
81 | type: Sequelize.QueryTypes.SELECT, | ||
82 | bind: { infoHash }, | ||
83 | raw: true | ||
84 | } | ||
85 | |||
86 | return VideoModel.sequelize.query(query, options) | ||
87 | .then(results => { | ||
88 | return results.length === 1 | ||
89 | }) | ||
90 | } | ||
91 | |||
92 | static buildP2PMediaLoaderInfoHashes (playlistUrl: string, videoFiles: VideoFileModel[]) { | ||
93 | const hashes: string[] = [] | ||
94 | |||
95 | // https://github.com/Novage/p2p-media-loader/blob/master/p2p-media-loader-core/lib/p2p-media-manager.ts#L97 | ||
96 | for (let i = 0; i < videoFiles.length; i++) { | ||
97 | hashes.push(sha1(`1${playlistUrl}+V${i}`)) | ||
98 | } | ||
99 | |||
100 | return hashes | ||
101 | } | ||
102 | |||
103 | static loadWithVideo (id: number) { | ||
104 | const options = { | ||
105 | include: [ | ||
106 | { | ||
107 | model: VideoModel.unscoped(), | ||
108 | required: true | ||
109 | } | ||
110 | ] | ||
111 | } | ||
112 | |||
113 | return VideoStreamingPlaylistModel.findById(id, options) | ||
114 | } | ||
115 | |||
116 | static getHlsPlaylistFilename (resolution: number) { | ||
117 | return resolution + '.m3u8' | ||
118 | } | ||
119 | |||
120 | static getMasterHlsPlaylistFilename () { | ||
121 | return 'master.m3u8' | ||
122 | } | ||
123 | |||
124 | static getHlsSha256SegmentsFilename () { | ||
125 | return 'segments-sha256.json' | ||
126 | } | ||
127 | |||
128 | static getHlsMasterPlaylistStaticPath (videoUUID: string) { | ||
129 | return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename()) | ||
130 | } | ||
131 | |||
132 | static getHlsPlaylistStaticPath (videoUUID: string, resolution: number) { | ||
133 | return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution)) | ||
134 | } | ||
135 | |||
136 | static getHlsSha256SegmentsStaticPath (videoUUID: string) { | ||
137 | return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename()) | ||
138 | } | ||
139 | |||
140 | getStringType () { | ||
141 | if (this.type === VideoStreamingPlaylistType.HLS) return 'hls' | ||
142 | |||
143 | return 'unknown' | ||
144 | } | ||
145 | |||
146 | getVideoRedundancyUrl (baseUrlHttp: string) { | ||
147 | return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getStringType() + '/' + this.Video.uuid | ||
148 | } | ||
149 | |||
150 | hasSameUniqueKeysThan (other: VideoStreamingPlaylistModel) { | ||
151 | return this.type === other.type && | ||
152 | this.videoId === other.videoId | ||
153 | } | ||
154 | } | ||
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 80a6c7832..702260772 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -52,7 +52,7 @@ import { | |||
52 | ACTIVITY_PUB, | 52 | ACTIVITY_PUB, |
53 | API_VERSION, | 53 | API_VERSION, |
54 | CONFIG, | 54 | CONFIG, |
55 | CONSTRAINTS_FIELDS, | 55 | CONSTRAINTS_FIELDS, HLS_PLAYLIST_DIRECTORY, HLS_REDUNDANCY_DIRECTORY, |
56 | PREVIEWS_SIZE, | 56 | PREVIEWS_SIZE, |
57 | REMOTE_SCHEME, | 57 | REMOTE_SCHEME, |
58 | STATIC_DOWNLOAD_PATHS, | 58 | STATIC_DOWNLOAD_PATHS, |
@@ -95,6 +95,7 @@ import * as validator from 'validator' | |||
95 | import { UserVideoHistoryModel } from '../account/user-video-history' | 95 | import { UserVideoHistoryModel } from '../account/user-video-history' |
96 | import { UserModel } from '../account/user' | 96 | import { UserModel } from '../account/user' |
97 | import { VideoImportModel } from './video-import' | 97 | import { VideoImportModel } from './video-import' |
98 | import { VideoStreamingPlaylistModel } from './video-streaming-playlist' | ||
98 | 99 | ||
99 | // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation | 100 | // 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[] = [ | 101 | const indexes: Sequelize.DefineIndexesOptions[] = [ |
@@ -159,7 +160,9 @@ export enum ScopeNames { | |||
159 | WITH_FILES = 'WITH_FILES', | 160 | WITH_FILES = 'WITH_FILES', |
160 | WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', | 161 | WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', |
161 | WITH_BLACKLISTED = 'WITH_BLACKLISTED', | 162 | WITH_BLACKLISTED = 'WITH_BLACKLISTED', |
162 | WITH_USER_HISTORY = 'WITH_USER_HISTORY' | 163 | WITH_USER_HISTORY = 'WITH_USER_HISTORY', |
164 | WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS', | ||
165 | WITH_USER_ID = 'WITH_USER_ID' | ||
163 | } | 166 | } |
164 | 167 | ||
165 | type ForAPIOptions = { | 168 | type ForAPIOptions = { |
@@ -463,6 +466,22 @@ type AvailableForListIDsOptions = { | |||
463 | 466 | ||
464 | return query | 467 | return query |
465 | }, | 468 | }, |
469 | [ ScopeNames.WITH_USER_ID ]: { | ||
470 | include: [ | ||
471 | { | ||
472 | attributes: [ 'accountId' ], | ||
473 | model: () => VideoChannelModel.unscoped(), | ||
474 | required: true, | ||
475 | include: [ | ||
476 | { | ||
477 | attributes: [ 'userId' ], | ||
478 | model: () => AccountModel.unscoped(), | ||
479 | required: true | ||
480 | } | ||
481 | ] | ||
482 | } | ||
483 | ] | ||
484 | }, | ||
466 | [ ScopeNames.WITH_ACCOUNT_DETAILS ]: { | 485 | [ ScopeNames.WITH_ACCOUNT_DETAILS ]: { |
467 | include: [ | 486 | include: [ |
468 | { | 487 | { |
@@ -527,22 +546,55 @@ type AvailableForListIDsOptions = { | |||
527 | } | 546 | } |
528 | ] | 547 | ] |
529 | }, | 548 | }, |
530 | [ ScopeNames.WITH_FILES ]: { | 549 | [ ScopeNames.WITH_FILES ]: (withRedundancies = false) => { |
531 | include: [ | 550 | let subInclude: any[] = [] |
532 | { | 551 | |
533 | model: () => VideoFileModel.unscoped(), | 552 | if (withRedundancies === true) { |
534 | // FIXME: typings | 553 | subInclude = [ |
535 | [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join | 554 | { |
536 | required: false, | 555 | attributes: [ 'fileUrl' ], |
537 | include: [ | 556 | model: VideoRedundancyModel.unscoped(), |
538 | { | 557 | required: false |
539 | attributes: [ 'fileUrl' ], | 558 | } |
540 | model: () => VideoRedundancyModel.unscoped(), | 559 | ] |
541 | required: false | 560 | } |
542 | } | 561 | |
543 | ] | 562 | return { |
544 | } | 563 | include: [ |
545 | ] | 564 | { |
565 | model: VideoFileModel.unscoped(), | ||
566 | // FIXME: typings | ||
567 | [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join | ||
568 | required: false, | ||
569 | include: subInclude | ||
570 | } | ||
571 | ] | ||
572 | } | ||
573 | }, | ||
574 | [ ScopeNames.WITH_STREAMING_PLAYLISTS ]: (withRedundancies = false) => { | ||
575 | let subInclude: any[] = [] | ||
576 | |||
577 | if (withRedundancies === true) { | ||
578 | subInclude = [ | ||
579 | { | ||
580 | attributes: [ 'fileUrl' ], | ||
581 | model: VideoRedundancyModel.unscoped(), | ||
582 | required: false | ||
583 | } | ||
584 | ] | ||
585 | } | ||
586 | |||
587 | return { | ||
588 | include: [ | ||
589 | { | ||
590 | model: VideoStreamingPlaylistModel.unscoped(), | ||
591 | // FIXME: typings | ||
592 | [ 'separate' as any ]: true, // We may have multiple streaming playlists, having multiple redundancies so let's separate this join | ||
593 | required: false, | ||
594 | include: subInclude | ||
595 | } | ||
596 | ] | ||
597 | } | ||
546 | }, | 598 | }, |
547 | [ ScopeNames.WITH_SCHEDULED_UPDATE ]: { | 599 | [ ScopeNames.WITH_SCHEDULED_UPDATE ]: { |
548 | include: [ | 600 | include: [ |
@@ -722,6 +774,16 @@ export class VideoModel extends Model<VideoModel> { | |||
722 | }) | 774 | }) |
723 | VideoFiles: VideoFileModel[] | 775 | VideoFiles: VideoFileModel[] |
724 | 776 | ||
777 | @HasMany(() => VideoStreamingPlaylistModel, { | ||
778 | foreignKey: { | ||
779 | name: 'videoId', | ||
780 | allowNull: false | ||
781 | }, | ||
782 | hooks: true, | ||
783 | onDelete: 'cascade' | ||
784 | }) | ||
785 | VideoStreamingPlaylists: VideoStreamingPlaylistModel[] | ||
786 | |||
725 | @HasMany(() => VideoShareModel, { | 787 | @HasMany(() => VideoShareModel, { |
726 | foreignKey: { | 788 | foreignKey: { |
727 | name: 'videoId', | 789 | name: 'videoId', |
@@ -847,6 +909,9 @@ export class VideoModel extends Model<VideoModel> { | |||
847 | tasks.push(instance.removeFile(file)) | 909 | tasks.push(instance.removeFile(file)) |
848 | tasks.push(instance.removeTorrent(file)) | 910 | tasks.push(instance.removeTorrent(file)) |
849 | }) | 911 | }) |
912 | |||
913 | // Remove playlists file | ||
914 | tasks.push(instance.removeStreamingPlaylist()) | ||
850 | } | 915 | } |
851 | 916 | ||
852 | // Do not wait video deletion because we could be in a transaction | 917 | // Do not wait video deletion because we could be in a transaction |
@@ -858,10 +923,6 @@ export class VideoModel extends Model<VideoModel> { | |||
858 | return undefined | 923 | return undefined |
859 | } | 924 | } |
860 | 925 | ||
861 | static list () { | ||
862 | return VideoModel.scope(ScopeNames.WITH_FILES).findAll() | ||
863 | } | ||
864 | |||
865 | static listLocal () { | 926 | static listLocal () { |
866 | const query = { | 927 | const query = { |
867 | where: { | 928 | where: { |
@@ -869,7 +930,7 @@ export class VideoModel extends Model<VideoModel> { | |||
869 | } | 930 | } |
870 | } | 931 | } |
871 | 932 | ||
872 | return VideoModel.scope(ScopeNames.WITH_FILES).findAll(query) | 933 | return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ]).findAll(query) |
873 | } | 934 | } |
874 | 935 | ||
875 | static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { | 936 | static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { |
@@ -1200,6 +1261,16 @@ export class VideoModel extends Model<VideoModel> { | |||
1200 | return VideoModel.findOne(options) | 1261 | return VideoModel.findOne(options) |
1201 | } | 1262 | } |
1202 | 1263 | ||
1264 | static loadWithRights (id: number | string, t?: Sequelize.Transaction) { | ||
1265 | const where = VideoModel.buildWhereIdOrUUID(id) | ||
1266 | const options = { | ||
1267 | where, | ||
1268 | transaction: t | ||
1269 | } | ||
1270 | |||
1271 | return VideoModel.scope([ ScopeNames.WITH_BLACKLISTED, ScopeNames.WITH_USER_ID ]).findOne(options) | ||
1272 | } | ||
1273 | |||
1203 | static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { | 1274 | static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { |
1204 | const where = VideoModel.buildWhereIdOrUUID(id) | 1275 | const where = VideoModel.buildWhereIdOrUUID(id) |
1205 | 1276 | ||
@@ -1212,8 +1283,8 @@ export class VideoModel extends Model<VideoModel> { | |||
1212 | return VideoModel.findOne(options) | 1283 | return VideoModel.findOne(options) |
1213 | } | 1284 | } |
1214 | 1285 | ||
1215 | static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) { | 1286 | static loadWithFiles (id: number, t?: Sequelize.Transaction, logging?: boolean) { |
1216 | return VideoModel.scope(ScopeNames.WITH_FILES) | 1287 | return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ]) |
1217 | .findById(id, { transaction: t, logging }) | 1288 | .findById(id, { transaction: t, logging }) |
1218 | } | 1289 | } |
1219 | 1290 | ||
@@ -1224,9 +1295,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1224 | } | 1295 | } |
1225 | } | 1296 | } |
1226 | 1297 | ||
1227 | return VideoModel | 1298 | return VideoModel.findOne(options) |
1228 | .scope([ ScopeNames.WITH_FILES ]) | ||
1229 | .findOne(options) | ||
1230 | } | 1299 | } |
1231 | 1300 | ||
1232 | static loadByUrl (url: string, transaction?: Sequelize.Transaction) { | 1301 | static loadByUrl (url: string, transaction?: Sequelize.Transaction) { |
@@ -1248,7 +1317,11 @@ export class VideoModel extends Model<VideoModel> { | |||
1248 | transaction | 1317 | transaction |
1249 | } | 1318 | } |
1250 | 1319 | ||
1251 | return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) | 1320 | return VideoModel.scope([ |
1321 | ScopeNames.WITH_ACCOUNT_DETAILS, | ||
1322 | ScopeNames.WITH_FILES, | ||
1323 | ScopeNames.WITH_STREAMING_PLAYLISTS | ||
1324 | ]).findOne(query) | ||
1252 | } | 1325 | } |
1253 | 1326 | ||
1254 | static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { | 1327 | static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { |
@@ -1263,9 +1336,37 @@ export class VideoModel extends Model<VideoModel> { | |||
1263 | const scopes = [ | 1336 | const scopes = [ |
1264 | ScopeNames.WITH_TAGS, | 1337 | ScopeNames.WITH_TAGS, |
1265 | ScopeNames.WITH_BLACKLISTED, | 1338 | ScopeNames.WITH_BLACKLISTED, |
1339 | ScopeNames.WITH_ACCOUNT_DETAILS, | ||
1340 | ScopeNames.WITH_SCHEDULED_UPDATE, | ||
1266 | ScopeNames.WITH_FILES, | 1341 | ScopeNames.WITH_FILES, |
1342 | ScopeNames.WITH_STREAMING_PLAYLISTS | ||
1343 | ] | ||
1344 | |||
1345 | if (userId) { | ||
1346 | scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings | ||
1347 | } | ||
1348 | |||
1349 | return VideoModel | ||
1350 | .scope(scopes) | ||
1351 | .findOne(options) | ||
1352 | } | ||
1353 | |||
1354 | static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) { | ||
1355 | const where = VideoModel.buildWhereIdOrUUID(id) | ||
1356 | |||
1357 | const options = { | ||
1358 | order: [ [ 'Tags', 'name', 'ASC' ] ], | ||
1359 | where, | ||
1360 | transaction: t | ||
1361 | } | ||
1362 | |||
1363 | const scopes = [ | ||
1364 | ScopeNames.WITH_TAGS, | ||
1365 | ScopeNames.WITH_BLACKLISTED, | ||
1267 | ScopeNames.WITH_ACCOUNT_DETAILS, | 1366 | ScopeNames.WITH_ACCOUNT_DETAILS, |
1268 | ScopeNames.WITH_SCHEDULED_UPDATE | 1367 | ScopeNames.WITH_SCHEDULED_UPDATE, |
1368 | { method: [ ScopeNames.WITH_FILES, true ] } as any, // FIXME: typings | ||
1369 | { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] } as any // FIXME: typings | ||
1269 | ] | 1370 | ] |
1270 | 1371 | ||
1271 | if (userId) { | 1372 | if (userId) { |
@@ -1612,6 +1713,14 @@ export class VideoModel extends Model<VideoModel> { | |||
1612 | .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) | 1713 | .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) |
1613 | } | 1714 | } |
1614 | 1715 | ||
1716 | removeStreamingPlaylist (isRedundancy = false) { | ||
1717 | const baseDir = isRedundancy ? HLS_REDUNDANCY_DIRECTORY : HLS_PLAYLIST_DIRECTORY | ||
1718 | |||
1719 | const filePath = join(baseDir, this.uuid) | ||
1720 | return remove(filePath) | ||
1721 | .catch(err => logger.warn('Cannot delete playlist directory %s.', filePath, { err })) | ||
1722 | } | ||
1723 | |||
1615 | isOutdated () { | 1724 | isOutdated () { |
1616 | if (this.isOwned()) return false | 1725 | if (this.isOwned()) return false |
1617 | 1726 | ||
@@ -1646,7 +1755,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1646 | 1755 | ||
1647 | generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) { | 1756 | generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) { |
1648 | const xs = this.getTorrentUrl(videoFile, baseUrlHttp) | 1757 | const xs = this.getTorrentUrl(videoFile, baseUrlHttp) |
1649 | const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] | 1758 | const announce = this.getTrackerUrls(baseUrlHttp, baseUrlWs) |
1650 | let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ] | 1759 | let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ] |
1651 | 1760 | ||
1652 | const redundancies = videoFile.RedundancyVideos | 1761 | const redundancies = videoFile.RedundancyVideos |
@@ -1663,6 +1772,10 @@ export class VideoModel extends Model<VideoModel> { | |||
1663 | return magnetUtil.encode(magnetHash) | 1772 | return magnetUtil.encode(magnetHash) |
1664 | } | 1773 | } |
1665 | 1774 | ||
1775 | getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) { | ||
1776 | return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] | ||
1777 | } | ||
1778 | |||
1666 | getThumbnailUrl (baseUrlHttp: string) { | 1779 | getThumbnailUrl (baseUrlHttp: string) { |
1667 | return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() | 1780 | return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() |
1668 | } | 1781 | } |
@@ -1686,4 +1799,8 @@ export class VideoModel extends Model<VideoModel> { | |||
1686 | getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) { | 1799 | getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) { |
1687 | return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile) | 1800 | return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile) |
1688 | } | 1801 | } |
1802 | |||
1803 | getBandwidthBits (videoFile: VideoFileModel) { | ||
1804 | return Math.ceil((videoFile.size * 8) / this.duration) | ||
1805 | } | ||
1689 | } | 1806 | } |