diff options
author | Chocobozzz <me@florianbigard.com> | 2018-07-12 19:02:00 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2018-07-16 11:50:08 +0200 |
commit | 40e87e9ecc54e3513fb586928330a7855eb192c6 (patch) | |
tree | af1111ecba85f9cd8286811ff332a67cf21be2f6 /server/models/video | |
parent | d4557fd3ecc8d4ed4fb0e5c868929bc36c959ed2 (diff) | |
download | PeerTube-40e87e9ecc54e3513fb586928330a7855eb192c6.tar.gz PeerTube-40e87e9ecc54e3513fb586928330a7855eb192c6.tar.zst PeerTube-40e87e9ecc54e3513fb586928330a7855eb192c6.zip |
Implement captions/subtitles
Diffstat (limited to 'server/models/video')
-rw-r--r-- | server/models/video/video-caption.ts | 173 | ||||
-rw-r--r-- | server/models/video/video.ts | 40 |
2 files changed, 206 insertions, 7 deletions
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts new file mode 100644 index 000000000..9920dfc7c --- /dev/null +++ b/server/models/video/video-caption.ts | |||
@@ -0,0 +1,173 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | import { | ||
3 | AllowNull, | ||
4 | BeforeDestroy, | ||
5 | BelongsTo, | ||
6 | Column, | ||
7 | CreatedAt, | ||
8 | ForeignKey, | ||
9 | Is, | ||
10 | Model, | ||
11 | Scopes, | ||
12 | Table, | ||
13 | UpdatedAt | ||
14 | } from 'sequelize-typescript' | ||
15 | import { throwIfNotValid } from '../utils' | ||
16 | import { VideoModel } from './video' | ||
17 | import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' | ||
18 | import { VideoCaption } from '../../../shared/models/videos/video-caption.model' | ||
19 | import { CONFIG, STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers' | ||
20 | import { join } from 'path' | ||
21 | import { logger } from '../../helpers/logger' | ||
22 | import { unlinkPromise } from '../../helpers/core-utils' | ||
23 | |||
24 | export enum ScopeNames { | ||
25 | WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE' | ||
26 | } | ||
27 | |||
28 | @Scopes({ | ||
29 | [ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: { | ||
30 | include: [ | ||
31 | { | ||
32 | attributes: [ 'uuid', 'remote' ], | ||
33 | model: () => VideoModel.unscoped(), | ||
34 | required: true | ||
35 | } | ||
36 | ] | ||
37 | } | ||
38 | }) | ||
39 | |||
40 | @Table({ | ||
41 | tableName: 'videoCaption', | ||
42 | indexes: [ | ||
43 | { | ||
44 | fields: [ 'videoId' ] | ||
45 | }, | ||
46 | { | ||
47 | fields: [ 'videoId', 'language' ], | ||
48 | unique: true | ||
49 | } | ||
50 | ] | ||
51 | }) | ||
52 | export class VideoCaptionModel extends Model<VideoCaptionModel> { | ||
53 | @CreatedAt | ||
54 | createdAt: Date | ||
55 | |||
56 | @UpdatedAt | ||
57 | updatedAt: Date | ||
58 | |||
59 | @AllowNull(false) | ||
60 | @Is('VideoCaptionLanguage', value => throwIfNotValid(value, isVideoCaptionLanguageValid, 'language')) | ||
61 | @Column | ||
62 | language: string | ||
63 | |||
64 | @ForeignKey(() => VideoModel) | ||
65 | @Column | ||
66 | videoId: number | ||
67 | |||
68 | @BelongsTo(() => VideoModel, { | ||
69 | foreignKey: { | ||
70 | allowNull: false | ||
71 | }, | ||
72 | onDelete: 'CASCADE' | ||
73 | }) | ||
74 | Video: VideoModel | ||
75 | |||
76 | @BeforeDestroy | ||
77 | static async removeFiles (instance: VideoCaptionModel) { | ||
78 | |||
79 | if (instance.isOwned()) { | ||
80 | if (!instance.Video) { | ||
81 | instance.Video = await instance.$get('Video') as VideoModel | ||
82 | } | ||
83 | |||
84 | logger.debug('Removing captions %s of video %s.', instance.Video.uuid, instance.language) | ||
85 | return instance.removeCaptionFile() | ||
86 | } | ||
87 | |||
88 | return undefined | ||
89 | } | ||
90 | |||
91 | static loadByVideoIdAndLanguage (videoId: string | number, language: string) { | ||
92 | const videoInclude = { | ||
93 | model: VideoModel.unscoped(), | ||
94 | attributes: [ 'id', 'remote', 'uuid' ], | ||
95 | where: { } | ||
96 | } | ||
97 | |||
98 | if (typeof videoId === 'string') videoInclude.where['uuid'] = videoId | ||
99 | else videoInclude.where['id'] = videoId | ||
100 | |||
101 | const query = { | ||
102 | where: { | ||
103 | language | ||
104 | }, | ||
105 | include: [ | ||
106 | videoInclude | ||
107 | ] | ||
108 | } | ||
109 | |||
110 | return VideoCaptionModel.findOne(query) | ||
111 | } | ||
112 | |||
113 | static insertOrReplaceLanguage (videoId: number, language: string, transaction: Sequelize.Transaction) { | ||
114 | const values = { | ||
115 | videoId, | ||
116 | language | ||
117 | } | ||
118 | |||
119 | return VideoCaptionModel.upsert(values, { transaction }) | ||
120 | } | ||
121 | |||
122 | static listVideoCaptions (videoId: number) { | ||
123 | const query = { | ||
124 | order: [ [ 'language', 'ASC' ] ], | ||
125 | where: { | ||
126 | videoId | ||
127 | } | ||
128 | } | ||
129 | |||
130 | return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query) | ||
131 | } | ||
132 | |||
133 | static getLanguageLabel (language: string) { | ||
134 | return VIDEO_LANGUAGES[language] || 'Unknown' | ||
135 | } | ||
136 | |||
137 | static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Sequelize.Transaction) { | ||
138 | const query = { | ||
139 | where: { | ||
140 | videoId | ||
141 | }, | ||
142 | transaction | ||
143 | } | ||
144 | |||
145 | return VideoCaptionModel.destroy(query) | ||
146 | } | ||
147 | |||
148 | isOwned () { | ||
149 | return this.Video.remote === false | ||
150 | } | ||
151 | |||
152 | toFormattedJSON (): VideoCaption { | ||
153 | return { | ||
154 | language: { | ||
155 | id: this.language, | ||
156 | label: VideoCaptionModel.getLanguageLabel(this.language) | ||
157 | }, | ||
158 | captionPath: this.getCaptionStaticPath() | ||
159 | } | ||
160 | } | ||
161 | |||
162 | getCaptionStaticPath () { | ||
163 | return join(STATIC_PATHS.VIDEO_CAPTIONS, this.getCaptionName()) | ||
164 | } | ||
165 | |||
166 | getCaptionName () { | ||
167 | return `${this.Video.uuid}-${this.language}.vtt` | ||
168 | } | ||
169 | |||
170 | removeCaptionFile () { | ||
171 | return unlinkPromise(CONFIG.STORAGE.CAPTIONS_DIR + this.getCaptionName()) | ||
172 | } | ||
173 | } | ||
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index ab33b7c99..74a3a5d05 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -92,6 +92,7 @@ import { VideoFileModel } from './video-file' | |||
92 | import { VideoShareModel } from './video-share' | 92 | import { VideoShareModel } from './video-share' |
93 | import { VideoTagModel } from './video-tag' | 93 | import { VideoTagModel } from './video-tag' |
94 | import { ScheduleVideoUpdateModel } from './schedule-video-update' | 94 | import { ScheduleVideoUpdateModel } from './schedule-video-update' |
95 | import { VideoCaptionModel } from './video-caption' | ||
95 | 96 | ||
96 | export enum ScopeNames { | 97 | export enum ScopeNames { |
97 | AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', | 98 | AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', |
@@ -526,6 +527,17 @@ export class VideoModel extends Model<VideoModel> { | |||
526 | }) | 527 | }) |
527 | ScheduleVideoUpdate: ScheduleVideoUpdateModel | 528 | ScheduleVideoUpdate: ScheduleVideoUpdateModel |
528 | 529 | ||
530 | @HasMany(() => VideoCaptionModel, { | ||
531 | foreignKey: { | ||
532 | name: 'videoId', | ||
533 | allowNull: false | ||
534 | }, | ||
535 | onDelete: 'cascade', | ||
536 | hooks: true, | ||
537 | ['separate' as any]: true | ||
538 | }) | ||
539 | VideoCaptions: VideoCaptionModel[] | ||
540 | |||
529 | @BeforeDestroy | 541 | @BeforeDestroy |
530 | static async sendDelete (instance: VideoModel, options) { | 542 | static async sendDelete (instance: VideoModel, options) { |
531 | if (instance.isOwned()) { | 543 | if (instance.isOwned()) { |
@@ -550,7 +562,7 @@ export class VideoModel extends Model<VideoModel> { | |||
550 | } | 562 | } |
551 | 563 | ||
552 | @BeforeDestroy | 564 | @BeforeDestroy |
553 | static async removeFilesAndSendDelete (instance: VideoModel) { | 565 | static async removeFiles (instance: VideoModel) { |
554 | const tasks: Promise<any>[] = [] | 566 | const tasks: Promise<any>[] = [] |
555 | 567 | ||
556 | logger.debug('Removing files of video %s.', instance.url) | 568 | logger.debug('Removing files of video %s.', instance.url) |
@@ -616,6 +628,11 @@ export class VideoModel extends Model<VideoModel> { | |||
616 | }, | 628 | }, |
617 | include: [ | 629 | include: [ |
618 | { | 630 | { |
631 | attributes: [ 'language' ], | ||
632 | model: VideoCaptionModel.unscoped(), | ||
633 | required: false | ||
634 | }, | ||
635 | { | ||
619 | attributes: [ 'id', 'url' ], | 636 | attributes: [ 'id', 'url' ], |
620 | model: VideoShareModel.unscoped(), | 637 | model: VideoShareModel.unscoped(), |
621 | required: false, | 638 | required: false, |
@@ -1028,15 +1045,15 @@ export class VideoModel extends Model<VideoModel> { | |||
1028 | videoFile.infoHash = parsedTorrent.infoHash | 1045 | videoFile.infoHash = parsedTorrent.infoHash |
1029 | } | 1046 | } |
1030 | 1047 | ||
1031 | getEmbedPath () { | 1048 | getEmbedStaticPath () { |
1032 | return '/videos/embed/' + this.uuid | 1049 | return '/videos/embed/' + this.uuid |
1033 | } | 1050 | } |
1034 | 1051 | ||
1035 | getThumbnailPath () { | 1052 | getThumbnailStaticPath () { |
1036 | return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) | 1053 | return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) |
1037 | } | 1054 | } |
1038 | 1055 | ||
1039 | getPreviewPath () { | 1056 | getPreviewStaticPath () { |
1040 | return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) | 1057 | return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) |
1041 | } | 1058 | } |
1042 | 1059 | ||
@@ -1077,9 +1094,9 @@ export class VideoModel extends Model<VideoModel> { | |||
1077 | views: this.views, | 1094 | views: this.views, |
1078 | likes: this.likes, | 1095 | likes: this.likes, |
1079 | dislikes: this.dislikes, | 1096 | dislikes: this.dislikes, |
1080 | thumbnailPath: this.getThumbnailPath(), | 1097 | thumbnailPath: this.getThumbnailStaticPath(), |
1081 | previewPath: this.getPreviewPath(), | 1098 | previewPath: this.getPreviewStaticPath(), |
1082 | embedPath: this.getEmbedPath(), | 1099 | embedPath: this.getEmbedStaticPath(), |
1083 | createdAt: this.createdAt, | 1100 | createdAt: this.createdAt, |
1084 | updatedAt: this.updatedAt, | 1101 | updatedAt: this.updatedAt, |
1085 | publishedAt: this.publishedAt, | 1102 | publishedAt: this.publishedAt, |
@@ -1247,6 +1264,14 @@ export class VideoModel extends Model<VideoModel> { | |||
1247 | href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid | 1264 | href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid |
1248 | }) | 1265 | }) |
1249 | 1266 | ||
1267 | const subtitleLanguage = [] | ||
1268 | for (const caption of this.VideoCaptions) { | ||
1269 | subtitleLanguage.push({ | ||
1270 | identifier: caption.language, | ||
1271 | name: VideoCaptionModel.getLanguageLabel(caption.language) | ||
1272 | }) | ||
1273 | } | ||
1274 | |||
1250 | return { | 1275 | return { |
1251 | type: 'Video' as 'Video', | 1276 | type: 'Video' as 'Video', |
1252 | id: this.url, | 1277 | id: this.url, |
@@ -1267,6 +1292,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1267 | mediaType: 'text/markdown', | 1292 | mediaType: 'text/markdown', |
1268 | content: this.getTruncatedDescription(), | 1293 | content: this.getTruncatedDescription(), |
1269 | support: this.support, | 1294 | support: this.support, |
1295 | subtitleLanguage, | ||
1270 | icon: { | 1296 | icon: { |
1271 | type: 'Image', | 1297 | type: 'Image', |
1272 | url: this.getThumbnailUrl(baseUrlHttp), | 1298 | url: this.getThumbnailUrl(baseUrlHttp), |