ニジカ投稿局 https://tv.nizika.tv
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

video-caption.ts 6.9 KiB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. import { VideoCaption, VideoCaptionObject } from '@peertube/peertube-models'
  2. import { buildUUID } from '@peertube/peertube-node-utils'
  3. import {
  4. MVideo,
  5. MVideoCaption,
  6. MVideoCaptionFormattable,
  7. MVideoCaptionLanguageUrl,
  8. MVideoCaptionVideo
  9. } from '@server/types/models/index.js'
  10. import { remove } from 'fs-extra/esm'
  11. import { join } from 'path'
  12. import { Op, OrderItem, Transaction } from 'sequelize'
  13. import {
  14. AllowNull,
  15. BeforeDestroy,
  16. BelongsTo,
  17. Column,
  18. CreatedAt,
  19. DataType,
  20. ForeignKey,
  21. Is, Scopes,
  22. Table,
  23. UpdatedAt
  24. } from 'sequelize-typescript'
  25. import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions.js'
  26. import { logger } from '../../helpers/logger.js'
  27. import { CONFIG } from '../../initializers/config.js'
  28. import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants.js'
  29. import { SequelizeModel, buildWhereIdOrUUID, throwIfNotValid } from '../shared/index.js'
  30. import { VideoModel } from './video.js'
  31. export enum ScopeNames {
  32. WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE'
  33. }
  34. @Scopes(() => ({
  35. [ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: {
  36. include: [
  37. {
  38. attributes: [ 'id', 'uuid', 'remote' ],
  39. model: VideoModel.unscoped(),
  40. required: true
  41. }
  42. ]
  43. }
  44. }))
  45. @Table({
  46. tableName: 'videoCaption',
  47. indexes: [
  48. {
  49. fields: [ 'filename' ],
  50. unique: true
  51. },
  52. {
  53. fields: [ 'videoId' ]
  54. },
  55. {
  56. fields: [ 'videoId', 'language' ],
  57. unique: true
  58. }
  59. ]
  60. })
  61. export class VideoCaptionModel extends SequelizeModel<VideoCaptionModel> {
  62. @CreatedAt
  63. createdAt: Date
  64. @UpdatedAt
  65. updatedAt: Date
  66. @AllowNull(false)
  67. @Is('VideoCaptionLanguage', value => throwIfNotValid(value, isVideoCaptionLanguageValid, 'language'))
  68. @Column
  69. language: string
  70. @AllowNull(false)
  71. @Column
  72. filename: string
  73. @AllowNull(true)
  74. @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
  75. fileUrl: string
  76. @AllowNull(false)
  77. @Column
  78. automaticallyGenerated: boolean
  79. @ForeignKey(() => VideoModel)
  80. @Column
  81. videoId: number
  82. @BelongsTo(() => VideoModel, {
  83. foreignKey: {
  84. allowNull: false
  85. },
  86. onDelete: 'CASCADE'
  87. })
  88. Video: Awaited<VideoModel>
  89. @BeforeDestroy
  90. static async removeFiles (instance: VideoCaptionModel, options) {
  91. if (!instance.Video) {
  92. instance.Video = await instance.$get('Video', { transaction: options.transaction })
  93. }
  94. if (instance.isOwned()) {
  95. logger.info('Removing caption %s.', instance.filename)
  96. try {
  97. await instance.removeCaptionFile()
  98. } catch (err) {
  99. logger.error('Cannot remove caption file %s.', instance.filename)
  100. }
  101. }
  102. return undefined
  103. }
  104. static async insertOrReplaceLanguage (caption: MVideoCaption, transaction: Transaction) {
  105. const existing = await VideoCaptionModel.loadByVideoIdAndLanguage(caption.videoId, caption.language, transaction)
  106. // Delete existing file
  107. if (existing) await existing.destroy({ transaction })
  108. return caption.save({ transaction })
  109. }
  110. // ---------------------------------------------------------------------------
  111. static loadByVideoIdAndLanguage (videoId: string | number, language: string, transaction?: Transaction): Promise<MVideoCaptionVideo> {
  112. const videoInclude = {
  113. model: VideoModel.unscoped(),
  114. attributes: [ 'id', 'name', 'remote', 'uuid', 'url' ],
  115. where: buildWhereIdOrUUID(videoId)
  116. }
  117. const query = {
  118. where: {
  119. language
  120. },
  121. include: [
  122. videoInclude
  123. ],
  124. transaction
  125. }
  126. return VideoCaptionModel.findOne(query)
  127. }
  128. static loadWithVideoByFilename (filename: string): Promise<MVideoCaptionVideo> {
  129. const query = {
  130. where: {
  131. filename
  132. },
  133. include: [
  134. {
  135. model: VideoModel.unscoped(),
  136. attributes: [ 'id', 'remote', 'uuid' ]
  137. }
  138. ]
  139. }
  140. return VideoCaptionModel.findOne(query)
  141. }
  142. // ---------------------------------------------------------------------------
  143. static async hasVideoCaption (videoId: number) {
  144. const query = {
  145. where: {
  146. videoId
  147. }
  148. }
  149. const result = await VideoCaptionModel.unscoped().findOne(query)
  150. return !!result
  151. }
  152. static listVideoCaptions (videoId: number, transaction?: Transaction): Promise<MVideoCaptionVideo[]> {
  153. const query = {
  154. order: [ [ 'language', 'ASC' ] ] as OrderItem[],
  155. where: {
  156. videoId
  157. },
  158. transaction
  159. }
  160. return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
  161. }
  162. static async listCaptionsOfMultipleVideos (videoIds: number[], transaction?: Transaction) {
  163. const query = {
  164. order: [ [ 'language', 'ASC' ] ] as OrderItem[],
  165. where: {
  166. videoId: {
  167. [Op.in]: videoIds
  168. }
  169. },
  170. transaction
  171. }
  172. const captions = await VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll<MVideoCaptionVideo>(query)
  173. const result: { [ id: number ]: MVideoCaptionVideo[] } = {}
  174. for (const id of videoIds) {
  175. result[id] = []
  176. }
  177. for (const caption of captions) {
  178. result[caption.videoId].push(caption)
  179. }
  180. return result
  181. }
  182. // ---------------------------------------------------------------------------
  183. static getLanguageLabel (language: string) {
  184. return VIDEO_LANGUAGES[language] || 'Unknown'
  185. }
  186. static generateCaptionName (language: string) {
  187. return `${buildUUID()}-${language}.vtt`
  188. }
  189. // ---------------------------------------------------------------------------
  190. toFormattedJSON (this: MVideoCaptionFormattable): VideoCaption {
  191. return {
  192. language: {
  193. id: this.language,
  194. label: VideoCaptionModel.getLanguageLabel(this.language)
  195. },
  196. automaticallyGenerated: this.automaticallyGenerated,
  197. captionPath: this.getCaptionStaticPath(),
  198. updatedAt: this.updatedAt.toISOString()
  199. }
  200. }
  201. toActivityPubObject (this: MVideoCaptionLanguageUrl, video: MVideo): VideoCaptionObject {
  202. return {
  203. identifier: this.language,
  204. name: VideoCaptionModel.getLanguageLabel(this.language),
  205. automaticallyGenerated: this.automaticallyGenerated,
  206. url: this.getFileUrl(video)
  207. }
  208. }
  209. // ---------------------------------------------------------------------------
  210. isOwned () {
  211. return this.Video.remote === false
  212. }
  213. getCaptionStaticPath (this: MVideoCaptionLanguageUrl) {
  214. return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.filename)
  215. }
  216. getFSPath () {
  217. return join(CONFIG.STORAGE.CAPTIONS_DIR, this.filename)
  218. }
  219. removeCaptionFile (this: MVideoCaption) {
  220. return remove(this.getFSPath())
  221. }
  222. getFileUrl (this: MVideoCaptionLanguageUrl, video: MVideo) {
  223. if (video.isOwned()) return WEBSERVER.URL + this.getCaptionStaticPath()
  224. return this.fileUrl
  225. }
  226. isEqual (this: MVideoCaption, other: MVideoCaption) {
  227. if (this.fileUrl) return this.fileUrl === other.fileUrl
  228. return this.filename === other.filename
  229. }
  230. }